@tldraw/store 5.2.0-next.ee0fa4d6244f → 5.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DOCS.md +790 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ImmutableMap.js +0 -13
- package/dist-cjs/lib/ImmutableMap.js.map +2 -2
- package/dist-cjs/lib/RecordType.js +1 -1
- package/dist-cjs/lib/RecordType.js.map +2 -2
- package/dist-cjs/lib/RecordsDiff.js +26 -14
- package/dist-cjs/lib/RecordsDiff.js.map +2 -2
- package/dist-cjs/lib/Store.js +6 -5
- package/dist-cjs/lib/Store.js.map +2 -2
- package/dist-cjs/lib/StoreSchema.js +1 -1
- package/dist-cjs/lib/StoreSchema.js.map +2 -2
- package/dist-cjs/lib/executeQuery.js +1 -1
- package/dist-cjs/lib/executeQuery.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ImmutableMap.mjs +0 -13
- package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
- package/dist-esm/lib/RecordType.mjs +1 -1
- package/dist-esm/lib/RecordType.mjs.map +2 -2
- package/dist-esm/lib/RecordsDiff.mjs +26 -14
- package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
- package/dist-esm/lib/Store.mjs +7 -6
- package/dist-esm/lib/Store.mjs.map +2 -2
- package/dist-esm/lib/StoreSchema.mjs +1 -1
- package/dist-esm/lib/StoreSchema.mjs.map +2 -2
- package/dist-esm/lib/executeQuery.mjs +1 -1
- package/dist-esm/lib/executeQuery.mjs.map +2 -2
- package/package.json +9 -5
- package/src/lib/{test/AtomMap.test.ts → AtomMap.test.ts} +77 -111
- package/src/lib/AtomSet.test.ts +116 -0
- package/src/lib/BaseRecord.test.ts +10 -22
- package/src/lib/ImmutableMap.test.ts +114 -71
- package/src/lib/ImmutableMap.ts +1 -0
- package/src/lib/IncrementalSetConstructor.test.ts +76 -81
- package/src/lib/RecordType.test.ts +216 -0
- package/src/lib/RecordType.ts +1 -1
- package/src/lib/RecordsDiff.test.ts +112 -106
- package/src/lib/RecordsDiff.ts +43 -18
- package/src/lib/Store.test.ts +570 -630
- package/src/lib/Store.ts +9 -10
- package/src/lib/StoreListeners.test.ts +462 -0
- package/src/lib/StoreQueries.test.ts +586 -434
- package/src/lib/StoreSchema.test.ts +1012 -174
- package/src/lib/StoreSchema.ts +1 -1
- package/src/lib/StoreSideEffects.test.ts +546 -158
- package/src/lib/devFreeze.test.ts +94 -124
- package/src/lib/executeQuery.test.ts +77 -31
- package/src/lib/executeQuery.ts +3 -1
- package/src/lib/migrate.test.ts +273 -296
- package/src/lib/setUtils.test.ts +38 -79
- package/src/lib/test/createMigrations.test.ts +0 -75
- package/src/lib/test/dependsOn.test.ts +0 -166
- package/src/lib/test/getMigrationsSince.test.ts +0 -121
- package/src/lib/test/migrate.test.ts +0 -118
- package/src/lib/test/migratePersistedRecord.test.ts +0 -265
- package/src/lib/test/migrationCaching.test.ts +0 -209
- package/src/lib/test/recordStore.test.ts +0 -1567
- package/src/lib/test/recordStoreQueries.test.ts +0 -814
- package/src/lib/test/recordType.test.ts +0 -19
- package/src/lib/test/sortMigrations.test.ts +0 -83
- package/src/lib/test/upgradeSchema.test.ts +0 -80
- package/src/lib/test/validate.test.ts +0 -178
- package/src/lib/test/validateMigrations.test.ts +0 -165
package/src/lib/Store.test.ts
CHANGED
|
@@ -1,827 +1,767 @@
|
|
|
1
|
+
import { react } from '@tldraw/state'
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
3
|
import { BaseRecord, RecordId } from './BaseRecord'
|
|
3
|
-
import {
|
|
4
|
+
import { createMigrationSequence } from './migrate'
|
|
4
5
|
import { createRecordType } from './RecordType'
|
|
5
6
|
import { createComputedCache, Store } from './Store'
|
|
6
7
|
import { StoreSchema } from './StoreSchema'
|
|
7
8
|
|
|
8
|
-
//
|
|
9
|
+
// Tests for SPEC.md §5 (reading and writing), §9 (validation), §10 (computed caches),
|
|
10
|
+
// and §21 (integrity). Rule IDs like [S3] in test names refer to that document.
|
|
11
|
+
|
|
9
12
|
interface Book extends BaseRecord<'book', RecordId<Book>> {
|
|
10
13
|
title: string
|
|
11
14
|
author: RecordId<Author>
|
|
12
15
|
numPages: number
|
|
13
|
-
genre?: string
|
|
14
|
-
inStock?: boolean
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
const Book = createRecordType<Book>('book', {
|
|
18
19
|
validator: {
|
|
19
|
-
validate
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
throw
|
|
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')
|
|
20
|
+
validate(value) {
|
|
21
|
+
const book = value as Book
|
|
22
|
+
if (!book.id.startsWith('book:')) throw Error('invalid book id')
|
|
23
|
+
if (book.typeName !== 'book') throw Error('invalid book typeName')
|
|
24
|
+
if (typeof book.title !== 'string') throw Error('invalid book title')
|
|
25
|
+
if (!Number.isFinite(book.numPages) || book.numPages < 0) {
|
|
26
|
+
throw Error('invalid book numPages')
|
|
41
27
|
}
|
|
42
|
-
|
|
43
|
-
throw new Error('Book inStock must be boolean if provided')
|
|
44
|
-
}
|
|
45
|
-
return r as Book
|
|
28
|
+
return book
|
|
46
29
|
},
|
|
47
30
|
},
|
|
48
31
|
scope: 'document',
|
|
49
|
-
}).withDefaultProperties(() => ({
|
|
50
|
-
inStock: true,
|
|
51
|
-
numPages: 100,
|
|
52
|
-
}))
|
|
32
|
+
}).withDefaultProperties(() => ({ numPages: 100 }))
|
|
53
33
|
|
|
54
34
|
interface Author extends BaseRecord<'author', RecordId<Author>> {
|
|
55
35
|
name: string
|
|
56
36
|
isPseudonym: boolean
|
|
57
|
-
birthYear?: number
|
|
58
37
|
}
|
|
59
38
|
|
|
60
39
|
const Author = createRecordType<Author>('author', {
|
|
61
40
|
validator: {
|
|
62
|
-
validate
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (typeof
|
|
68
|
-
|
|
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
|
|
41
|
+
validate(value) {
|
|
42
|
+
const author = value as Author
|
|
43
|
+
if (!author.id.startsWith('author:')) throw Error('invalid author id')
|
|
44
|
+
if (author.typeName !== 'author') throw Error('invalid author typeName')
|
|
45
|
+
if (typeof author.name !== 'string') throw Error('invalid author name')
|
|
46
|
+
if (typeof author.isPseudonym !== 'boolean') throw Error('invalid author isPseudonym')
|
|
47
|
+
return author
|
|
86
48
|
},
|
|
87
49
|
},
|
|
88
50
|
scope: 'document',
|
|
89
|
-
}).withDefaultProperties(() => ({
|
|
90
|
-
isPseudonym: false,
|
|
91
|
-
}))
|
|
51
|
+
}).withDefaultProperties(() => ({ isPseudonym: false }))
|
|
92
52
|
|
|
93
53
|
interface Visit extends BaseRecord<'visit', RecordId<Visit>> {
|
|
94
54
|
visitorName: string
|
|
95
|
-
booksInBasket: RecordId<Book>[]
|
|
96
|
-
timestamp: number
|
|
97
55
|
}
|
|
98
56
|
|
|
99
57
|
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
58
|
scope: 'session',
|
|
130
|
-
}).withDefaultProperties(() => ({
|
|
131
|
-
visitorName: 'Anonymous',
|
|
132
|
-
booksInBasket: [],
|
|
133
|
-
timestamp: Date.now(),
|
|
134
|
-
}))
|
|
59
|
+
}).withDefaultProperties(() => ({ visitorName: 'Anonymous' }))
|
|
135
60
|
|
|
136
61
|
interface Cursor extends BaseRecord<'cursor', RecordId<Cursor>> {
|
|
137
62
|
x: number
|
|
138
63
|
y: number
|
|
139
|
-
userId: string
|
|
140
64
|
}
|
|
141
65
|
|
|
142
66
|
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
67
|
scope: 'presence',
|
|
168
68
|
})
|
|
169
69
|
|
|
170
70
|
type LibraryType = Book | Author | Visit | Cursor
|
|
171
71
|
|
|
172
|
-
|
|
72
|
+
const schema = () =>
|
|
73
|
+
StoreSchema.create<LibraryType>({
|
|
74
|
+
book: Book,
|
|
75
|
+
author: Author,
|
|
76
|
+
visit: Visit,
|
|
77
|
+
cursor: Cursor,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('Store: reading and writing (S)', () => {
|
|
173
81
|
let store: Store<LibraryType>
|
|
174
82
|
|
|
175
83
|
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
|
-
})
|
|
84
|
+
store = new Store({ props: {}, schema: schema() })
|
|
185
85
|
})
|
|
186
86
|
|
|
187
87
|
afterEach(() => {
|
|
188
88
|
store.dispose()
|
|
189
89
|
})
|
|
190
90
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const book = Book.create({
|
|
195
|
-
title: 'The Hobbit',
|
|
196
|
-
author: author.id,
|
|
197
|
-
numPages: 310,
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
store.put([author, book])
|
|
91
|
+
it('[S1] a new store is empty', () => {
|
|
92
|
+
expect(store.allRecords()).toEqual([])
|
|
93
|
+
})
|
|
201
94
|
|
|
202
|
-
|
|
203
|
-
|
|
95
|
+
it('[S1] initialData populates the store', () => {
|
|
96
|
+
const author = Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
|
|
97
|
+
const populated = new Store({
|
|
98
|
+
props: {},
|
|
99
|
+
schema: schema(),
|
|
100
|
+
initialData: { [author.id]: author } as any,
|
|
204
101
|
})
|
|
102
|
+
expect(populated.allRecords()).toEqual([author])
|
|
103
|
+
populated.dispose()
|
|
104
|
+
})
|
|
205
105
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
106
|
+
it('[S1] records read from the store are frozen in dev/test builds', () => {
|
|
107
|
+
const author = Author.create({ name: 'J.R.R Tolkein' })
|
|
108
|
+
store.put([author])
|
|
109
|
+
expect(Object.isFrozen(store.get(author.id))).toBe(true)
|
|
110
|
+
})
|
|
209
111
|
|
|
210
|
-
|
|
211
|
-
|
|
112
|
+
it('[S2] put creates records and get/has read them', () => {
|
|
113
|
+
const author = Author.create({ name: 'J.R.R Tolkein' })
|
|
114
|
+
const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310 })
|
|
212
115
|
|
|
213
|
-
|
|
214
|
-
})
|
|
116
|
+
store.put([author, book])
|
|
215
117
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
store.put([author, book])
|
|
225
|
-
expect(store.has(author.id)).toBe(true)
|
|
226
|
-
expect(store.has(book.id)).toBe(true)
|
|
118
|
+
expect(store.get(author.id)).toEqual(author)
|
|
119
|
+
expect(store.get(book.id)).toEqual(book)
|
|
120
|
+
expect(store.has(author.id)).toBe(true)
|
|
121
|
+
expect(store.has(Book.createId('missing'))).toBe(false)
|
|
122
|
+
expect(store.get(Book.createId('missing'))).toBeUndefined()
|
|
123
|
+
})
|
|
227
124
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
})
|
|
125
|
+
it('[S2] put updates records whose ids are already present', () => {
|
|
126
|
+
const author = Author.create({ name: 'J.R.R Tolkein' })
|
|
127
|
+
store.put([author])
|
|
232
128
|
|
|
233
|
-
|
|
234
|
-
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
235
|
-
store.put([author])
|
|
129
|
+
store.put([{ ...author, name: 'John Ronald Reuel Tolkien' }])
|
|
236
130
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
name: 'John Ronald Reuel Tolkien',
|
|
240
|
-
}))
|
|
131
|
+
expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien')
|
|
132
|
+
})
|
|
241
133
|
|
|
242
|
-
|
|
243
|
-
})
|
|
134
|
+
it('[S3] re-putting the stored object is a complete no-op', () => {
|
|
135
|
+
const author = Author.create({ name: 'J.R.R Tolkein' })
|
|
136
|
+
store.put([author])
|
|
244
137
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const book = Book.create({
|
|
248
|
-
title: 'The Hobbit',
|
|
249
|
-
author: author.id,
|
|
250
|
-
numPages: 310,
|
|
251
|
-
})
|
|
138
|
+
const listener = vi.fn()
|
|
139
|
+
store.listen(listener)
|
|
252
140
|
|
|
253
|
-
|
|
254
|
-
|
|
141
|
+
store.update(author.id, (a) => a)
|
|
142
|
+
store.put([store.get(author.id)!])
|
|
255
143
|
|
|
256
|
-
|
|
257
|
-
expect(store.allRecords()).toHaveLength(0)
|
|
258
|
-
})
|
|
144
|
+
expect(listener).not.toHaveBeenCalled()
|
|
259
145
|
})
|
|
260
146
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
})
|
|
147
|
+
it('[S3] a no-op put produces no history entry even among real changes', () => {
|
|
148
|
+
const author = Author.create({ name: 'J.R.R Tolkein' })
|
|
149
|
+
store.put([author])
|
|
275
150
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
151
|
+
const other = Author.create({ name: 'Ursula K. Le Guin' })
|
|
152
|
+
const diff = store.extractingChanges(() => {
|
|
153
|
+
store.put([store.get(author.id)!, other])
|
|
279
154
|
})
|
|
280
155
|
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
})
|
|
156
|
+
expect(Object.keys(diff.updated)).toEqual([])
|
|
157
|
+
expect(Object.keys(diff.added)).toEqual([other.id])
|
|
299
158
|
})
|
|
300
159
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const book = Book.create({
|
|
305
|
-
title: 'The Hobbit',
|
|
306
|
-
author: author.id,
|
|
307
|
-
numPages: 310,
|
|
308
|
-
})
|
|
160
|
+
it('[S4] update applies an updater function to the current record', () => {
|
|
161
|
+
const author = Author.create({ name: 'J.R.R Tolkein' })
|
|
162
|
+
store.put([author])
|
|
309
163
|
|
|
310
|
-
|
|
311
|
-
store.put([author, book])
|
|
312
|
-
})
|
|
164
|
+
store.update(author.id, (current) => ({ ...current, name: 'Jimmy Tolks' }))
|
|
313
165
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
expect(Object.keys(changes.updated)).toHaveLength(0)
|
|
317
|
-
expect(Object.keys(changes.removed)).toHaveLength(0)
|
|
318
|
-
})
|
|
166
|
+
expect(store.get(author.id)?.name).toBe('Jimmy Tolks')
|
|
167
|
+
})
|
|
319
168
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
store.put([author])
|
|
169
|
+
it('[S4] update of a missing id logs an error and does not throw', () => {
|
|
170
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
323
171
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
172
|
+
expect(() => {
|
|
173
|
+
store.update(Author.createId('missing'), (a) => a)
|
|
174
|
+
}).not.toThrow()
|
|
327
175
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
})
|
|
176
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
177
|
+
consoleSpy.mockRestore()
|
|
178
|
+
})
|
|
332
179
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
180
|
+
it('[S5] remove deletes records and ignores missing ids', () => {
|
|
181
|
+
const author = Author.create({ name: 'J.R.R Tolkein' })
|
|
182
|
+
const book = Book.create({ title: 'The Hobbit', author: author.id })
|
|
183
|
+
store.put([author, book])
|
|
336
184
|
|
|
337
|
-
|
|
338
|
-
store.remove([author.id])
|
|
339
|
-
})
|
|
185
|
+
store.remove([author.id, Book.createId('missing')])
|
|
340
186
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
expect(changes.removed).toHaveProperty(author.id)
|
|
344
|
-
})
|
|
187
|
+
expect(store.has(author.id)).toBe(false)
|
|
188
|
+
expect(store.has(book.id)).toBe(true)
|
|
345
189
|
})
|
|
346
190
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
191
|
+
it('[S5] removing nothing produces no history entry', () => {
|
|
192
|
+
const diff = store.extractingChanges(() => {
|
|
193
|
+
store.remove([Author.createId('missing')])
|
|
194
|
+
})
|
|
195
|
+
expect(diff).toEqual({ added: {}, updated: {}, removed: {} })
|
|
196
|
+
})
|
|
351
197
|
|
|
352
|
-
|
|
353
|
-
|
|
198
|
+
it('[S5] clear removes all records', () => {
|
|
199
|
+
store.put([Author.create({ name: 'J.R.R Tolkein' }), Visit.create({ visitorName: 'John Doe' })])
|
|
200
|
+
expect(store.allRecords()).toHaveLength(2)
|
|
354
201
|
|
|
355
|
-
|
|
356
|
-
|
|
202
|
+
store.clear()
|
|
203
|
+
expect(store.allRecords()).toHaveLength(0)
|
|
204
|
+
})
|
|
357
205
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
source: 'user',
|
|
362
|
-
changes: expect.objectContaining({
|
|
363
|
-
added: expect.objectContaining({
|
|
364
|
-
[author.id]: author,
|
|
365
|
-
}),
|
|
366
|
-
}),
|
|
367
|
-
})
|
|
368
|
-
)
|
|
206
|
+
it('[S6] get is reactive; unsafeGetWithoutCapture is not', () => {
|
|
207
|
+
const author = Author.create({ name: 'J.R.R Tolkein' })
|
|
208
|
+
store.put([author])
|
|
369
209
|
|
|
370
|
-
|
|
210
|
+
const reactiveReads: (string | undefined)[] = []
|
|
211
|
+
const unsafeReads: (string | undefined)[] = []
|
|
212
|
+
const stop = react('reader', () => {
|
|
213
|
+
reactiveReads.push(store.get(author.id)?.name)
|
|
214
|
+
unsafeReads.push(store.unsafeGetWithoutCapture(author.id)?.name)
|
|
215
|
+
})
|
|
371
216
|
|
|
372
|
-
|
|
373
|
-
title: 'The Hobbit',
|
|
374
|
-
author: author.id,
|
|
375
|
-
numPages: 310,
|
|
376
|
-
})
|
|
377
|
-
store.put([book])
|
|
217
|
+
store.update(author.id, (a) => ({ ...a, name: 'Jimmy Tolks' }))
|
|
378
218
|
|
|
379
|
-
|
|
380
|
-
|
|
219
|
+
expect(reactiveReads).toEqual(['J.R.R Tolkein', 'Jimmy Tolks'])
|
|
220
|
+
stop()
|
|
381
221
|
|
|
382
|
-
|
|
383
|
-
|
|
222
|
+
const unsafeOnly: (string | undefined)[] = []
|
|
223
|
+
const stop2 = react('unsafe-reader', () => {
|
|
224
|
+
unsafeOnly.push(store.unsafeGetWithoutCapture(author.id)?.name)
|
|
384
225
|
})
|
|
226
|
+
store.update(author.id, (a) => ({ ...a, name: 'John Ronald' }))
|
|
227
|
+
// the effect did not re-run because nothing was captured
|
|
228
|
+
expect(unsafeOnly).toEqual(['Jimmy Tolks'])
|
|
229
|
+
stop2()
|
|
230
|
+
})
|
|
231
|
+
})
|
|
385
232
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const remoteListener = vi.fn()
|
|
389
|
-
|
|
390
|
-
store.listen(userListener, { source: 'user', scope: 'all' })
|
|
391
|
-
store.listen(remoteListener, { source: 'remote', scope: 'all' })
|
|
233
|
+
describe('Store: serialization and snapshots (S)', () => {
|
|
234
|
+
let store: Store<LibraryType>
|
|
392
235
|
|
|
393
|
-
|
|
236
|
+
beforeEach(() => {
|
|
237
|
+
store = new Store({ props: {}, schema: schema() })
|
|
238
|
+
store.put([
|
|
239
|
+
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
|
|
240
|
+
Book.create({
|
|
241
|
+
title: 'The Hobbit',
|
|
242
|
+
id: Book.createId('hobbit'),
|
|
243
|
+
author: Author.createId('tolkein'),
|
|
244
|
+
numPages: 300,
|
|
245
|
+
}),
|
|
246
|
+
Visit.create({ visitorName: 'John Doe', id: Visit.createId('doe') }),
|
|
247
|
+
Cursor.create({ x: 1, y: 2, id: Cursor.createId('c') }),
|
|
248
|
+
])
|
|
249
|
+
})
|
|
394
250
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
251
|
+
afterEach(() => {
|
|
252
|
+
store.dispose()
|
|
253
|
+
})
|
|
398
254
|
|
|
399
|
-
|
|
400
|
-
|
|
255
|
+
it('[S7] serialize defaults to document scope', () => {
|
|
256
|
+
const typeNames = Object.values(store.serialize()).map((r) => r.typeName)
|
|
257
|
+
expect(typeNames.sort()).toEqual(['author', 'book'])
|
|
258
|
+
})
|
|
401
259
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
260
|
+
it('[S7] serialize filters by the given scope, and "all" includes everything', () => {
|
|
261
|
+
expect(Object.values(store.serialize('session')).map((r) => r.typeName)).toEqual(['visit'])
|
|
262
|
+
expect(Object.values(store.serialize('presence')).map((r) => r.typeName)).toEqual(['cursor'])
|
|
263
|
+
expect(
|
|
264
|
+
Object.values(store.serialize('all'))
|
|
265
|
+
.map((r) => r.typeName)
|
|
266
|
+
.sort()
|
|
267
|
+
).toEqual(['author', 'book', 'cursor', 'visit'])
|
|
268
|
+
})
|
|
412
269
|
|
|
413
|
-
|
|
414
|
-
|
|
270
|
+
it('[S7] scopedTypes maps each scope to its type names', () => {
|
|
271
|
+
expect(store.scopedTypes).toEqual({
|
|
272
|
+
document: new Set(['book', 'author']),
|
|
273
|
+
session: new Set(['visit']),
|
|
274
|
+
presence: new Set(['cursor']),
|
|
415
275
|
})
|
|
276
|
+
})
|
|
416
277
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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)
|
|
278
|
+
it('[S8] getStoreSnapshot bundles the serialized records and schema', () => {
|
|
279
|
+
const snapshot = store.getStoreSnapshot('all')
|
|
280
|
+
expect(snapshot.store).toEqual(store.serialize('all'))
|
|
281
|
+
expect(snapshot.schema).toEqual(store.schema.serialize())
|
|
282
|
+
})
|
|
436
283
|
|
|
437
|
-
|
|
438
|
-
|
|
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)
|
|
284
|
+
it('[S9] loadStoreSnapshot replaces all current data', () => {
|
|
285
|
+
const snapshot = store.getStoreSnapshot()
|
|
441
286
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
287
|
+
const store2 = new Store({ props: {}, schema: schema() })
|
|
288
|
+
store2.put([Author.create({ name: 'Someone Else' })])
|
|
289
|
+
store2.loadStoreSnapshot(snapshot)
|
|
445
290
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
291
|
+
expect(store2.serialize('all')).toEqual(store.serialize('document'))
|
|
292
|
+
expect(store2.getStoreSnapshot()).toEqual(snapshot)
|
|
293
|
+
store2.dispose()
|
|
294
|
+
})
|
|
450
295
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
store.put([author])
|
|
296
|
+
it('[S9] loadStoreSnapshot does not run side effects', () => {
|
|
297
|
+
const snapshot = store.getStoreSnapshot()
|
|
454
298
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
299
|
+
const store2 = new Store({ props: {}, schema: schema() })
|
|
300
|
+
const afterCreate = vi.fn()
|
|
301
|
+
const beforeCreate = vi.fn((r: Book) => r)
|
|
302
|
+
store2.sideEffects.registerAfterCreateHandler('book', afterCreate)
|
|
303
|
+
store2.sideEffects.registerBeforeCreateHandler('book', beforeCreate)
|
|
458
304
|
|
|
459
|
-
|
|
460
|
-
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
461
|
-
expect(listener).not.toHaveBeenCalled()
|
|
305
|
+
store2.loadStoreSnapshot(snapshot)
|
|
462
306
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
307
|
+
expect(beforeCreate).not.toHaveBeenCalled()
|
|
308
|
+
expect(afterCreate).not.toHaveBeenCalled()
|
|
309
|
+
// side effects are re-enabled afterwards
|
|
310
|
+
store2.put([Book.create({ title: 'New', author: Author.createId('x') })])
|
|
311
|
+
expect(afterCreate).toHaveBeenCalledTimes(1)
|
|
312
|
+
store2.dispose()
|
|
313
|
+
})
|
|
470
314
|
|
|
471
|
-
|
|
472
|
-
|
|
315
|
+
it('[S9] loadStoreSnapshot migrates the snapshot first', () => {
|
|
316
|
+
const snapshot = store.getStoreSnapshot()
|
|
317
|
+
const up = vi.fn((s: any) => {
|
|
318
|
+
s['book:hobbit'].numPages = 42
|
|
473
319
|
})
|
|
474
|
-
})
|
|
475
320
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
321
|
+
const store2 = new Store<LibraryType>({
|
|
322
|
+
props: {},
|
|
323
|
+
schema: StoreSchema.create<LibraryType>(
|
|
324
|
+
{ book: Book, author: Author, visit: Visit, cursor: Cursor },
|
|
325
|
+
{
|
|
326
|
+
migrations: [
|
|
327
|
+
createMigrationSequence({
|
|
328
|
+
sequenceId: 'com.tldraw',
|
|
329
|
+
retroactive: true,
|
|
330
|
+
sequence: [{ id: 'com.tldraw/1', scope: 'store', up }],
|
|
331
|
+
}),
|
|
332
|
+
],
|
|
333
|
+
}
|
|
334
|
+
),
|
|
335
|
+
})
|
|
480
336
|
|
|
481
|
-
|
|
337
|
+
store2.loadStoreSnapshot(snapshot)
|
|
482
338
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
339
|
+
expect(up).toHaveBeenCalledTimes(1)
|
|
340
|
+
expect((store2.get(Book.createId('hobbit')) as Book).numPages).toBe(42)
|
|
341
|
+
store2.dispose()
|
|
342
|
+
})
|
|
486
343
|
|
|
487
|
-
|
|
344
|
+
it('[S9] storage-scope migrations run during loadStoreSnapshot', () => {
|
|
345
|
+
const snapshot = store.getStoreSnapshot()
|
|
346
|
+
const up = vi.fn((storage: any) => {
|
|
347
|
+
const book = storage.get('book:hobbit')
|
|
348
|
+
storage.set('book:hobbit', { ...book, numPages: 42 })
|
|
349
|
+
storage.delete('author:tolkein')
|
|
350
|
+
})
|
|
488
351
|
|
|
489
|
-
|
|
352
|
+
const store2 = new Store<LibraryType>({
|
|
353
|
+
props: {},
|
|
354
|
+
schema: StoreSchema.create<LibraryType>(
|
|
355
|
+
{ book: Book, author: Author, visit: Visit, cursor: Cursor },
|
|
356
|
+
{
|
|
357
|
+
migrations: [
|
|
358
|
+
createMigrationSequence({
|
|
359
|
+
sequenceId: 'com.tldraw',
|
|
360
|
+
retroactive: true,
|
|
361
|
+
sequence: [{ id: 'com.tldraw/1', scope: 'storage', up }],
|
|
362
|
+
}),
|
|
363
|
+
],
|
|
364
|
+
}
|
|
365
|
+
),
|
|
490
366
|
})
|
|
491
367
|
|
|
492
|
-
|
|
493
|
-
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
368
|
+
store2.loadStoreSnapshot(snapshot)
|
|
494
369
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
370
|
+
expect(up).toHaveBeenCalledTimes(1)
|
|
371
|
+
expect((store2.get(Book.createId('hobbit')) as Book).numPages).toBe(42)
|
|
372
|
+
expect(store2.get(Author.createId('tolkein'))).toBeUndefined()
|
|
373
|
+
store2.dispose()
|
|
374
|
+
})
|
|
498
375
|
|
|
499
|
-
|
|
500
|
-
|
|
376
|
+
it('[S9] loadStoreSnapshot throws on migration failure and leaves the store unchanged', () => {
|
|
377
|
+
const snapshot = store.getStoreSnapshot()
|
|
501
378
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
store.mergeRemoteChanges(() => {
|
|
506
|
-
// This should throw
|
|
507
|
-
})
|
|
508
|
-
})
|
|
509
|
-
}).toThrow('Cannot merge remote changes while in atomic operation')
|
|
379
|
+
const store2 = new Store({
|
|
380
|
+
props: {},
|
|
381
|
+
schema: StoreSchema.create<Book>({ book: Book }), // no author type
|
|
510
382
|
})
|
|
383
|
+
const existing = Book.create({ title: 'Keep me', author: Author.createId('x') })
|
|
384
|
+
store2.put([existing])
|
|
385
|
+
|
|
386
|
+
expect(() => {
|
|
387
|
+
store2.loadStoreSnapshot(snapshot as any)
|
|
388
|
+
}).toThrow('Missing definition for record type author')
|
|
389
|
+
expect(store2.get(existing.id)).toEqual(existing)
|
|
390
|
+
store2.dispose()
|
|
511
391
|
})
|
|
512
392
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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' })
|
|
393
|
+
it('[S10] migrateSnapshot returns the snapshot stamped with the current schema', () => {
|
|
394
|
+
const snapshot = store.getStoreSnapshot()
|
|
395
|
+
const migrated = store.migrateSnapshot(snapshot)
|
|
396
|
+
expect(migrated).toEqual(snapshot) // no migrations needed
|
|
523
397
|
|
|
524
|
-
|
|
398
|
+
// a snapshot with an older schema is migrated and stamped with the current one
|
|
399
|
+
const store2 = new Store<LibraryType>({
|
|
400
|
+
props: {},
|
|
401
|
+
schema: StoreSchema.create<LibraryType>(
|
|
402
|
+
{ book: Book, author: Author, visit: Visit, cursor: Cursor },
|
|
403
|
+
{
|
|
404
|
+
migrations: [
|
|
405
|
+
createMigrationSequence({
|
|
406
|
+
sequenceId: 'com.tldraw',
|
|
407
|
+
retroactive: true,
|
|
408
|
+
sequence: [{ id: 'com.tldraw/1', scope: 'record', up: (r) => r }],
|
|
409
|
+
}),
|
|
410
|
+
],
|
|
411
|
+
}
|
|
412
|
+
),
|
|
525
413
|
})
|
|
414
|
+
const migrated2 = store2.migrateSnapshot(snapshot)
|
|
415
|
+
expect(snapshot.schema).toEqual({ schemaVersion: 2, sequences: {} })
|
|
416
|
+
expect(migrated2.schema).toEqual({ schemaVersion: 2, sequences: { 'com.tldraw': 1 } })
|
|
417
|
+
expect(migrated2.schema).toEqual(store2.schema.serialize())
|
|
418
|
+
store2.dispose()
|
|
526
419
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
})
|
|
420
|
+
expect(() =>
|
|
421
|
+
store.migrateSnapshot({ store: {}, schema: { schemaVersion: -1 } } as any)
|
|
422
|
+
).toThrow('Failed to migrate snapshot')
|
|
423
|
+
})
|
|
424
|
+
})
|
|
537
425
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const sessionRecords = Object.values(sessionSerialized)
|
|
426
|
+
describe('Store: validation (V)', () => {
|
|
427
|
+
let store: Store<LibraryType>
|
|
541
428
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
429
|
+
beforeEach(() => {
|
|
430
|
+
store = new Store({ props: {}, schema: schema() })
|
|
431
|
+
})
|
|
545
432
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
433
|
+
afterEach(() => {
|
|
434
|
+
store.dispose()
|
|
435
|
+
})
|
|
549
436
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
437
|
+
it('[V1] put validates created records', () => {
|
|
438
|
+
expect(() => {
|
|
439
|
+
store.put([
|
|
440
|
+
Book.create({
|
|
441
|
+
// @ts-expect-error - deliberately invalid data
|
|
442
|
+
title: 4,
|
|
443
|
+
author: Author.createId('tolkein'),
|
|
444
|
+
}),
|
|
445
|
+
])
|
|
446
|
+
}).toThrow('invalid book title')
|
|
447
|
+
expect(store.allRecords()).toEqual([])
|
|
448
|
+
})
|
|
555
449
|
|
|
556
|
-
|
|
557
|
-
|
|
450
|
+
it('[V1] put validates updated records', () => {
|
|
451
|
+
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkein') })
|
|
452
|
+
store.put([book])
|
|
558
453
|
|
|
559
|
-
|
|
560
|
-
|
|
454
|
+
expect(() => {
|
|
455
|
+
store.put([{ ...book, numPages: -1 }])
|
|
456
|
+
}).toThrow('invalid book numPages')
|
|
457
|
+
expect(store.get(book.id)).toEqual(book)
|
|
458
|
+
})
|
|
561
459
|
|
|
562
|
-
|
|
460
|
+
it('[V1] the store constructor validates initial data', () => {
|
|
461
|
+
const author = Author.create({ name: 'J.R.R Tolkein' })
|
|
462
|
+
expect(() => {
|
|
463
|
+
new Store({
|
|
563
464
|
props: {},
|
|
564
|
-
schema:
|
|
565
|
-
|
|
566
|
-
author: Author,
|
|
567
|
-
visit: Visit,
|
|
568
|
-
cursor: Cursor,
|
|
569
|
-
}),
|
|
465
|
+
schema: schema(),
|
|
466
|
+
initialData: { [author.id]: { ...author, name: 4 } } as any,
|
|
570
467
|
})
|
|
468
|
+
}).toThrow('invalid author name')
|
|
469
|
+
})
|
|
571
470
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
471
|
+
it('[V2] updates pass the previous record to validateUsingKnownGoodVersion', () => {
|
|
472
|
+
const validateUsingKnownGoodVersion = vi.fn((_prev: Author, next: unknown) => next as Author)
|
|
473
|
+
const TrackedAuthor = createRecordType<Author>('author', {
|
|
474
|
+
validator: {
|
|
475
|
+
validate: (r) => r as Author,
|
|
476
|
+
validateUsingKnownGoodVersion,
|
|
477
|
+
},
|
|
478
|
+
scope: 'document',
|
|
580
479
|
})
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const migratedSnapshot = store.migrateSnapshot(snapshot)
|
|
585
|
-
|
|
586
|
-
expect(migratedSnapshot).toEqual(snapshot) // No migrations needed
|
|
480
|
+
const trackedStore = new Store({
|
|
481
|
+
props: {},
|
|
482
|
+
schema: StoreSchema.create<Author>({ author: TrackedAuthor }),
|
|
587
483
|
})
|
|
588
484
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
schema: { version: -1 }, // Invalid version
|
|
593
|
-
} as any
|
|
485
|
+
const author = Author.create({ name: 'J.R.R Tolkein' })
|
|
486
|
+
trackedStore.put([author])
|
|
487
|
+
expect(validateUsingKnownGoodVersion).not.toHaveBeenCalled()
|
|
594
488
|
|
|
595
|
-
|
|
596
|
-
|
|
489
|
+
const updated = { ...author, name: 'Jimmy Tolks' }
|
|
490
|
+
trackedStore.put([updated])
|
|
491
|
+
expect(validateUsingKnownGoodVersion).toHaveBeenCalledWith(author, updated)
|
|
492
|
+
trackedStore.dispose()
|
|
597
493
|
})
|
|
598
494
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const computeExpensiveData = vi.fn((book: Book) => `expensive-${book.title}`)
|
|
495
|
+
it('[V3] a validation throw rolls back the whole operation', () => {
|
|
496
|
+
const goodAuthor = Author.create({ name: 'Fine' })
|
|
602
497
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
498
|
+
expect(() => {
|
|
499
|
+
store.put([
|
|
500
|
+
goodAuthor,
|
|
501
|
+
Book.create({
|
|
502
|
+
// @ts-expect-error - deliberately invalid data
|
|
503
|
+
title: 4,
|
|
504
|
+
author: goodAuthor.id,
|
|
505
|
+
}),
|
|
506
|
+
])
|
|
507
|
+
}).toThrow()
|
|
611
508
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
509
|
+
// the valid record earlier in the same put was rolled back too
|
|
510
|
+
expect(store.get(goodAuthor.id)).toBeUndefined()
|
|
511
|
+
expect(store.allRecords()).toEqual([])
|
|
512
|
+
})
|
|
615
513
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
514
|
+
it('[V4] the validation failure handler result is stored, on create and update alike', () => {
|
|
515
|
+
const FixableAuthor = createRecordType<Author>('author', {
|
|
516
|
+
validator: {
|
|
517
|
+
validate(value) {
|
|
518
|
+
const author = value as Author
|
|
519
|
+
if (typeof author.name !== 'string') throw Error('invalid author name')
|
|
520
|
+
return author
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
scope: 'document',
|
|
524
|
+
})
|
|
525
|
+
const recoveringStore = new Store<Author>({
|
|
526
|
+
props: {},
|
|
527
|
+
schema: StoreSchema.create<Author>(
|
|
528
|
+
{ author: FixableAuthor },
|
|
529
|
+
{
|
|
530
|
+
onValidationFailure: ({ record }) => ({ ...record, name: 'Recovered' }) as Author,
|
|
531
|
+
}
|
|
532
|
+
),
|
|
620
533
|
})
|
|
621
534
|
|
|
622
|
-
|
|
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])
|
|
535
|
+
const id = FixableAuthor.createId('a')
|
|
632
536
|
|
|
633
|
-
|
|
634
|
-
|
|
537
|
+
// create
|
|
538
|
+
recoveringStore.put([{ id, typeName: 'author', name: 4, isPseudonym: false } as any])
|
|
539
|
+
expect(recoveringStore.get(id)!.name).toBe('Recovered')
|
|
635
540
|
|
|
636
|
-
|
|
637
|
-
|
|
541
|
+
// update
|
|
542
|
+
recoveringStore.put([{ id, typeName: 'author', name: 'Valid', isPseudonym: false } as any])
|
|
543
|
+
recoveringStore.put([{ id, typeName: 'author', name: 5, isPseudonym: false } as any])
|
|
544
|
+
expect(recoveringStore.get(id)!.name).toBe('Recovered')
|
|
545
|
+
recoveringStore.dispose()
|
|
546
|
+
})
|
|
638
547
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
548
|
+
it('[V4] a validator that substitutes a transformed record has it stored on update', () => {
|
|
549
|
+
const NormalizingAuthor = createRecordType<Author>('author', {
|
|
550
|
+
validator: {
|
|
551
|
+
validate(value) {
|
|
552
|
+
const author = value as Author
|
|
553
|
+
return { ...author, name: author.name.trim() }
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
scope: 'document',
|
|
557
|
+
})
|
|
558
|
+
const normalizingStore = new Store<Author>({
|
|
559
|
+
props: {},
|
|
560
|
+
schema: StoreSchema.create<Author>({ author: NormalizingAuthor }),
|
|
643
561
|
})
|
|
562
|
+
|
|
563
|
+
const id = NormalizingAuthor.createId('a')
|
|
564
|
+
normalizingStore.put([
|
|
565
|
+
{ id, typeName: 'author', name: ' padded ', isPseudonym: false } as any,
|
|
566
|
+
])
|
|
567
|
+
expect(normalizingStore.get(id)!.name).toBe('padded')
|
|
568
|
+
|
|
569
|
+
normalizingStore.put([
|
|
570
|
+
{ id, typeName: 'author', name: ' padded again ', isPseudonym: false } as any,
|
|
571
|
+
])
|
|
572
|
+
expect(normalizingStore.get(id)!.name).toBe('padded again')
|
|
573
|
+
normalizingStore.dispose()
|
|
644
574
|
})
|
|
645
575
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
576
|
+
it('[V5] store.validate re-validates every record', () => {
|
|
577
|
+
const author = Author.create({ name: 'J.R.R Tolkein' })
|
|
578
|
+
const book = Book.create({ title: 'The Hobbit', author: author.id })
|
|
579
|
+
store.put([author, book])
|
|
580
|
+
|
|
581
|
+
const validateRecord = vi.spyOn(store.schema, 'validateRecord')
|
|
582
|
+
store.validate('tests')
|
|
583
|
+
expect(validateRecord).toHaveBeenCalledTimes(2)
|
|
584
|
+
expect(validateRecord).toHaveBeenCalledWith(store, author, 'tests', null)
|
|
585
|
+
expect(validateRecord).toHaveBeenCalledWith(store, book, 'tests', null)
|
|
586
|
+
validateRecord.mockRestore()
|
|
587
|
+
})
|
|
588
|
+
})
|
|
651
589
|
|
|
652
|
-
|
|
590
|
+
describe('computed caches (CC)', () => {
|
|
591
|
+
let store: Store<LibraryType>
|
|
653
592
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
numPages: 310,
|
|
658
|
-
})
|
|
659
|
-
store.put([book])
|
|
593
|
+
beforeEach(() => {
|
|
594
|
+
store = new Store({ props: {}, schema: schema() })
|
|
595
|
+
})
|
|
660
596
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
})
|
|
597
|
+
afterEach(() => {
|
|
598
|
+
store.dispose()
|
|
599
|
+
})
|
|
665
600
|
|
|
666
|
-
|
|
667
|
-
|
|
601
|
+
it('[CC1] get derives for existing records and returns undefined for missing ones', () => {
|
|
602
|
+
const derive = vi.fn((book: Book) => `derived-${book.title}`)
|
|
603
|
+
const cache = store.createComputedCache('cache', derive)
|
|
668
604
|
|
|
669
|
-
|
|
605
|
+
expect(cache.get(Book.createId('missing'))).toBeUndefined()
|
|
606
|
+
expect(derive).not.toHaveBeenCalled()
|
|
670
607
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
author: Author.createId('tolkien'),
|
|
674
|
-
numPages: 310,
|
|
675
|
-
})
|
|
676
|
-
store.put([book])
|
|
608
|
+
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
|
|
609
|
+
store.put([book])
|
|
677
610
|
|
|
678
|
-
|
|
679
|
-
expect(result).toBe(`The Hobbit-${store.id}`)
|
|
680
|
-
expect(derive).toHaveBeenCalledTimes(1)
|
|
681
|
-
})
|
|
611
|
+
expect(cache.get(book.id)).toBe('derived-The Hobbit')
|
|
682
612
|
})
|
|
683
613
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const book = Book.create({
|
|
688
|
-
title: 'The Hobbit',
|
|
689
|
-
author: author.id,
|
|
690
|
-
numPages: 310,
|
|
691
|
-
})
|
|
614
|
+
it('[CC2] values are cached until the record changes', () => {
|
|
615
|
+
const derive = vi.fn((book: Book) => `derived-${book.title}`)
|
|
616
|
+
const cache = store.createComputedCache('cache', derive)
|
|
692
617
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
[author.id]: author,
|
|
696
|
-
[book.id]: book,
|
|
697
|
-
},
|
|
698
|
-
updated: {},
|
|
699
|
-
removed: {},
|
|
700
|
-
}
|
|
618
|
+
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
|
|
619
|
+
store.put([book])
|
|
701
620
|
|
|
702
|
-
|
|
621
|
+
expect(cache.get(book.id)).toBe('derived-The Hobbit')
|
|
622
|
+
expect(cache.get(book.id)).toBe('derived-The Hobbit')
|
|
623
|
+
expect(derive).toHaveBeenCalledTimes(1)
|
|
703
624
|
|
|
704
|
-
|
|
705
|
-
expect(store.get(book.id)).toEqual(book)
|
|
706
|
-
})
|
|
625
|
+
store.update(book.id, (b) => ({ ...b, title: 'The Hobbit: Updated' }) as Book)
|
|
707
626
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
627
|
+
expect(cache.get(book.id)).toBe('derived-The Hobbit: Updated')
|
|
628
|
+
expect(derive).toHaveBeenCalledTimes(2)
|
|
629
|
+
})
|
|
711
630
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
[author.id]: [author, updatedAuthor],
|
|
717
|
-
},
|
|
718
|
-
removed: {},
|
|
719
|
-
}
|
|
631
|
+
it('[CC1] a deleted record makes get return undefined again', () => {
|
|
632
|
+
const cache = store.createComputedCache('cache', (book: Book) => book.title)
|
|
633
|
+
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
|
|
634
|
+
store.put([book])
|
|
720
635
|
|
|
721
|
-
|
|
636
|
+
expect(cache.get(book.id)).toBe('The Hobbit')
|
|
637
|
+
store.remove([book.id])
|
|
638
|
+
expect(cache.get(book.id)).toBeUndefined()
|
|
639
|
+
})
|
|
722
640
|
|
|
723
|
-
|
|
641
|
+
it('[CC3] areRecordsEqual controls which record changes invalidate the cache', () => {
|
|
642
|
+
const derive = vi.fn((book: Book) => book.title)
|
|
643
|
+
const cache = store.createComputedCache('cache', derive, {
|
|
644
|
+
areRecordsEqual: (a, b) => a.title === b.title,
|
|
724
645
|
})
|
|
725
646
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
const diff: RecordsDiff<LibraryType> = {
|
|
731
|
-
added: {},
|
|
732
|
-
updated: {},
|
|
733
|
-
removed: {
|
|
734
|
-
[author.id]: author,
|
|
735
|
-
},
|
|
736
|
-
}
|
|
647
|
+
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
|
|
648
|
+
store.put([book])
|
|
649
|
+
expect(cache.get(book.id)).toBe('The Hobbit')
|
|
650
|
+
expect(derive).toHaveBeenCalledTimes(1)
|
|
737
651
|
|
|
738
|
-
|
|
652
|
+
// a change the equality function ignores does not re-derive
|
|
653
|
+
store.update(book.id, (b) => ({ ...b, numPages: 999 }) as Book)
|
|
654
|
+
expect(cache.get(book.id)).toBe('The Hobbit')
|
|
655
|
+
expect(derive).toHaveBeenCalledTimes(1)
|
|
739
656
|
|
|
740
|
-
|
|
741
|
-
})
|
|
657
|
+
// a change it observes does
|
|
658
|
+
store.update(book.id, (b) => ({ ...b, title: 'New Title' }) as Book)
|
|
659
|
+
expect(cache.get(book.id)).toBe('New Title')
|
|
660
|
+
expect(derive).toHaveBeenCalledTimes(2)
|
|
742
661
|
})
|
|
743
662
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
})
|
|
663
|
+
it('[CC3] areResultsEqual keeps the previous result object for equal results', () => {
|
|
664
|
+
const cache = store.createComputedCache(
|
|
665
|
+
'cache',
|
|
666
|
+
(book: Book) => ({ words: book.title.split(' ') }),
|
|
667
|
+
{ areResultsEqual: (a, b) => a.words.join() === b.words.join() }
|
|
668
|
+
)
|
|
760
669
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const afterCreate = vi.fn()
|
|
670
|
+
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
|
|
671
|
+
store.put([book])
|
|
672
|
+
const first = cache.get(book.id)
|
|
765
673
|
|
|
766
|
-
|
|
767
|
-
|
|
674
|
+
// the record changed, but the derived result is "equal" -> same object back
|
|
675
|
+
store.update(book.id, (b) => ({ ...b, numPages: 999 }) as Book)
|
|
676
|
+
expect(cache.get(book.id)).toBe(first)
|
|
677
|
+
})
|
|
768
678
|
|
|
769
|
-
|
|
770
|
-
|
|
679
|
+
it('[CC4] the standalone createComputedCache keys caches per context object', () => {
|
|
680
|
+
const derive = vi.fn((context: Store<LibraryType>, book: Book) => `${book.title}-${context.id}`)
|
|
681
|
+
const cache = createComputedCache('standalone', derive)
|
|
771
682
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
683
|
+
const store2 = new Store({ props: {}, schema: schema() })
|
|
684
|
+
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
|
|
685
|
+
store.put([book])
|
|
686
|
+
store2.put([book])
|
|
775
687
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
688
|
+
expect(cache.get(store, book.id)).toBe(`The Hobbit-${store.id}`)
|
|
689
|
+
expect(cache.get(store2, book.id)).toBe(`The Hobbit-${store2.id}`)
|
|
690
|
+
expect(derive).toHaveBeenCalledTimes(2)
|
|
691
|
+
store2.dispose()
|
|
692
|
+
})
|
|
779
693
|
|
|
780
|
-
|
|
781
|
-
|
|
694
|
+
it('[CC4] the standalone cache accepts objects containing a store', () => {
|
|
695
|
+
const cache = createComputedCache(
|
|
696
|
+
'standalone',
|
|
697
|
+
(context: { store: Store<LibraryType> }, book: Book) => `${book.title}-${context.store.id}`
|
|
698
|
+
)
|
|
782
699
|
|
|
783
|
-
|
|
784
|
-
|
|
700
|
+
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
|
|
701
|
+
store.put([book])
|
|
785
702
|
|
|
786
|
-
|
|
787
|
-
|
|
703
|
+
expect(cache.get({ store }, book.id)).toBe(`The Hobbit-${store.id}`)
|
|
704
|
+
})
|
|
788
705
|
|
|
789
|
-
|
|
790
|
-
|
|
706
|
+
it('[CC5] createCache calls create once per record and not for missing ids', () => {
|
|
707
|
+
const create = vi.fn((id: any, recordSignal: any) => {
|
|
708
|
+
// return the record signal itself: the cached value is the record
|
|
709
|
+
return recordSignal
|
|
791
710
|
})
|
|
711
|
+
const cache = store.createCache(create)
|
|
792
712
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const afterDelete = vi.fn()
|
|
796
|
-
|
|
797
|
-
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
798
|
-
store.put([author])
|
|
713
|
+
expect(cache.get(Book.createId('missing'))).toBeUndefined()
|
|
714
|
+
expect(create).not.toHaveBeenCalled()
|
|
799
715
|
|
|
800
|
-
|
|
801
|
-
|
|
716
|
+
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
|
|
717
|
+
store.put([book])
|
|
802
718
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
719
|
+
expect(cache.get(book.id)).toEqual(book)
|
|
720
|
+
cache.get(book.id)
|
|
721
|
+
expect(create).toHaveBeenCalledTimes(1)
|
|
722
|
+
})
|
|
723
|
+
})
|
|
808
724
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
725
|
+
describe('integrity (IC)', () => {
|
|
726
|
+
it('[IC1] ensureStoreIsUsable creates the integrity checker once and runs it', () => {
|
|
727
|
+
const integrityChecker = vi.fn()
|
|
728
|
+
const createIntegrityChecker = vi.fn(() => integrityChecker)
|
|
729
|
+
const integritySchema = StoreSchema.create<LibraryType>(
|
|
730
|
+
{ book: Book, author: Author, visit: Visit, cursor: Cursor },
|
|
731
|
+
{ createIntegrityChecker: createIntegrityChecker as any }
|
|
732
|
+
)
|
|
733
|
+
const store = new Store({ props: {}, schema: integritySchema })
|
|
734
|
+
|
|
735
|
+
store.ensureStoreIsUsable()
|
|
736
|
+
store.ensureStoreIsUsable()
|
|
737
|
+
|
|
738
|
+
expect(createIntegrityChecker).toHaveBeenCalledTimes(1)
|
|
739
|
+
expect(createIntegrityChecker).toHaveBeenCalledWith(store)
|
|
740
|
+
expect(integrityChecker).toHaveBeenCalledTimes(2)
|
|
741
|
+
store.dispose()
|
|
742
|
+
})
|
|
812
743
|
|
|
813
|
-
|
|
814
|
-
|
|
744
|
+
it('[IC1] the integrity checker runs after mergeRemoteChanges', () => {
|
|
745
|
+
const integrityChecker = vi.fn()
|
|
746
|
+
const integritySchema = StoreSchema.create<LibraryType>(
|
|
747
|
+
{ book: Book, author: Author, visit: Visit, cursor: Cursor },
|
|
748
|
+
{ createIntegrityChecker: (() => integrityChecker) as any }
|
|
749
|
+
)
|
|
750
|
+
const store = new Store({ props: {}, schema: integritySchema })
|
|
815
751
|
|
|
816
|
-
|
|
817
|
-
store.put([
|
|
752
|
+
store.mergeRemoteChanges(() => {
|
|
753
|
+
store.put([Author.create({ name: 'J.R.R Tolkein' })])
|
|
754
|
+
})
|
|
818
755
|
|
|
819
|
-
|
|
820
|
-
|
|
756
|
+
expect(integrityChecker).toHaveBeenCalled()
|
|
757
|
+
store.dispose()
|
|
758
|
+
})
|
|
821
759
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
760
|
+
it('[IC2] markAsPossiblyCorrupted sets a readable flag', () => {
|
|
761
|
+
const store = new Store({ props: {}, schema: schema() })
|
|
762
|
+
expect(store.isPossiblyCorrupted()).toBe(false)
|
|
763
|
+
store.markAsPossiblyCorrupted()
|
|
764
|
+
expect(store.isPossiblyCorrupted()).toBe(true)
|
|
765
|
+
store.dispose()
|
|
826
766
|
})
|
|
827
767
|
})
|