@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
|
@@ -1,240 +1,628 @@
|
|
|
1
|
+
import { react } from '@tldraw/state'
|
|
2
|
+
import { beforeEach, describe, expect, it, test, vi } from 'vitest'
|
|
1
3
|
import { BaseRecord, RecordId } from './BaseRecord'
|
|
2
4
|
import { createRecordType } from './RecordType'
|
|
3
|
-
import { Store } from './Store'
|
|
5
|
+
import { HistoryEntry, Store } from './Store'
|
|
4
6
|
import { StoreSchema } from './StoreSchema'
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
// Tests for SPEC.md §6 (atomic operations and the side-effect flush) and §7 (side effect
|
|
9
|
+
// handlers). Rule IDs like [AO4] in test names refer to that document.
|
|
10
|
+
|
|
11
|
+
interface Book extends BaseRecord<'book', RecordId<Book>> {
|
|
12
|
+
title: string
|
|
13
|
+
numPages: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const Book = createRecordType<Book>('book', {
|
|
17
|
+
validator: { validate: (book) => book as Book },
|
|
18
|
+
scope: 'document',
|
|
19
|
+
}).withDefaultProperties(() => ({ numPages: 1 }))
|
|
20
|
+
|
|
21
|
+
interface Author extends BaseRecord<'author', RecordId<Author>> {
|
|
7
22
|
name: string
|
|
8
|
-
value: number
|
|
9
23
|
}
|
|
10
24
|
|
|
11
|
-
const
|
|
12
|
-
validator: {
|
|
13
|
-
validate: (record) => record as TestRecord,
|
|
14
|
-
},
|
|
25
|
+
const Author = createRecordType<Author>('author', {
|
|
26
|
+
validator: { validate: (author) => author as Author },
|
|
15
27
|
scope: 'document',
|
|
16
|
-
})
|
|
17
|
-
name: '',
|
|
18
|
-
value: 0,
|
|
19
|
-
}))
|
|
28
|
+
})
|
|
20
29
|
|
|
21
|
-
|
|
22
|
-
let store: Store<TestRecord>
|
|
30
|
+
type LibraryType = Book | Author
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
const book1Id = Book.createId('darkness')
|
|
33
|
+
const book1 = Book.create({ title: 'the left hand of darkness', id: book1Id })
|
|
34
|
+
const book2Id = Book.createId('dispossessed')
|
|
35
|
+
const book2 = Book.create({ title: 'the dispossessed', id: book2Id })
|
|
36
|
+
|
|
37
|
+
let store: Store<LibraryType>
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
store = new Store({
|
|
40
|
+
props: {},
|
|
41
|
+
schema: StoreSchema.create<LibraryType>({ book: Book, author: Author }),
|
|
27
42
|
})
|
|
43
|
+
})
|
|
28
44
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
describe('side effect handlers (SE)', () => {
|
|
46
|
+
it('[SE1] handlers chain in registration order, each receiving the previous output', () => {
|
|
47
|
+
store.sideEffects.registerBeforeCreateHandler('book', (record) => ({
|
|
48
|
+
...record,
|
|
49
|
+
numPages: record.numPages + 10,
|
|
50
|
+
}))
|
|
51
|
+
store.sideEffects.registerBeforeCreateHandler('book', (record) => ({
|
|
52
|
+
...record,
|
|
53
|
+
numPages: record.numPages * 2,
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
store.put([Book.create({ title: 'test', numPages: 5, id: book1Id })])
|
|
57
|
+
|
|
58
|
+
// first handler: 5 + 10 = 15, second handler: 15 * 2 = 30
|
|
59
|
+
expect(store.get(book1Id)!.numPages).toBe(30)
|
|
60
|
+
})
|
|
37
61
|
|
|
38
|
-
|
|
39
|
-
|
|
62
|
+
it('[SE1] each register call returns a remover', () => {
|
|
63
|
+
const handler = vi.fn((record: Book) => ({ ...record, title: 'transformed' }))
|
|
64
|
+
const cleanup = store.sideEffects.registerBeforeCreateHandler('book', handler)
|
|
40
65
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
})
|
|
66
|
+
cleanup()
|
|
67
|
+
store.put([book1])
|
|
44
68
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
value: record.value + 10,
|
|
49
|
-
}))
|
|
50
|
-
store.sideEffects.registerBeforeCreateHandler('test', (record) => ({
|
|
51
|
-
...record,
|
|
52
|
-
value: record.value * 2,
|
|
53
|
-
}))
|
|
69
|
+
expect(handler).not.toHaveBeenCalled()
|
|
70
|
+
expect(store.get(book1Id)!.title).toBe(book1.title)
|
|
71
|
+
})
|
|
54
72
|
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
it('[SE1] register() registers many handlers and returns a single cleanup', () => {
|
|
74
|
+
const beforeCreate = vi.fn((record: Book) => record)
|
|
75
|
+
const afterCreate = vi.fn()
|
|
76
|
+
const beforeChange = vi.fn((_prev: Book, next: Book) => next)
|
|
77
|
+
const afterChange = vi.fn()
|
|
78
|
+
const beforeDelete = vi.fn()
|
|
79
|
+
const afterDelete = vi.fn()
|
|
57
80
|
|
|
58
|
-
|
|
59
|
-
|
|
81
|
+
const cleanup = store.sideEffects.register({
|
|
82
|
+
book: { beforeCreate, afterCreate, beforeChange, afterChange, beforeDelete, afterDelete },
|
|
60
83
|
})
|
|
84
|
+
|
|
85
|
+
store.put([book1])
|
|
86
|
+
store.update(book1Id, (b) => ({ ...b, numPages: 2 }))
|
|
87
|
+
store.remove([book1Id])
|
|
88
|
+
|
|
89
|
+
expect(beforeCreate).toHaveBeenCalledTimes(1)
|
|
90
|
+
expect(afterCreate).toHaveBeenCalledTimes(1)
|
|
91
|
+
expect(beforeChange).toHaveBeenCalledTimes(1)
|
|
92
|
+
expect(afterChange).toHaveBeenCalledTimes(1)
|
|
93
|
+
expect(beforeDelete).toHaveBeenCalledTimes(1)
|
|
94
|
+
expect(afterDelete).toHaveBeenCalledTimes(1)
|
|
95
|
+
|
|
96
|
+
cleanup()
|
|
97
|
+
store.put([book1])
|
|
98
|
+
store.remove([book1Id])
|
|
99
|
+
expect(beforeCreate).toHaveBeenCalledTimes(1)
|
|
100
|
+
expect(afterDelete).toHaveBeenCalledTimes(1)
|
|
61
101
|
})
|
|
62
102
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
let capturedRecord: TestRecord | null = null
|
|
67
|
-
store.sideEffects.registerAfterCreateHandler('test', (record, source) => {
|
|
68
|
-
handlerCalled = true
|
|
69
|
-
capturedRecord = record
|
|
70
|
-
expect(source).toBe('user')
|
|
71
|
-
})
|
|
103
|
+
it('[SE1] handlers only fire for their registered type', () => {
|
|
104
|
+
const bookHandler = vi.fn()
|
|
105
|
+
store.sideEffects.registerAfterCreateHandler('book', bookHandler)
|
|
72
106
|
|
|
73
|
-
|
|
74
|
-
|
|
107
|
+
store.put([Author.create({ name: 'Ursula K. Le Guin' })])
|
|
108
|
+
expect(bookHandler).not.toHaveBeenCalled()
|
|
109
|
+
})
|
|
75
110
|
|
|
76
|
-
|
|
77
|
-
|
|
111
|
+
it('[SE2] beforeCreate transforms the record that gets stored', () => {
|
|
112
|
+
store.sideEffects.registerBeforeCreateHandler('book', (record, source) => {
|
|
113
|
+
expect(source).toBe('user')
|
|
114
|
+
return { ...record, title: 'transformed' }
|
|
78
115
|
})
|
|
116
|
+
|
|
117
|
+
store.put([book1])
|
|
118
|
+
expect(store.get(book1Id)!.title).toBe('transformed')
|
|
79
119
|
})
|
|
80
120
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
121
|
+
it('[SE3] beforeChange transforms the update', () => {
|
|
122
|
+
store.put([book1])
|
|
123
|
+
|
|
124
|
+
store.sideEffects.registerBeforeChangeHandler('book', (_prev, next) => ({
|
|
125
|
+
...next,
|
|
126
|
+
title: next.title + '_transformed',
|
|
127
|
+
}))
|
|
128
|
+
|
|
129
|
+
store.update(book1Id, (b) => ({ ...b, title: 'updated' }))
|
|
85
130
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
name: next.name + '_transformed',
|
|
89
|
-
}))
|
|
131
|
+
expect(store.get(book1Id)!.title).toBe('updated_transformed')
|
|
132
|
+
})
|
|
90
133
|
|
|
91
|
-
|
|
134
|
+
it('[SE3] returning prev from beforeChange blocks the update entirely', () => {
|
|
135
|
+
store.put([book1])
|
|
92
136
|
|
|
93
|
-
|
|
137
|
+
store.sideEffects.registerBeforeChangeHandler('book', (prev, next) => {
|
|
138
|
+
if (next.numPages > 100) return prev
|
|
139
|
+
return next
|
|
94
140
|
})
|
|
95
141
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
store.put([record])
|
|
142
|
+
const listener = vi.fn()
|
|
143
|
+
store.listen(listener)
|
|
99
144
|
|
|
100
|
-
|
|
101
|
-
if (next.value > 100) return prev
|
|
102
|
-
return next
|
|
103
|
-
})
|
|
145
|
+
store.update(book1Id, (b) => ({ ...b, numPages: 200 }))
|
|
104
146
|
|
|
105
|
-
|
|
147
|
+
expect(store.get(book1Id)!.numPages).toBe(1) // unchanged
|
|
148
|
+
expect(listener).not.toHaveBeenCalled() // no history entry either
|
|
149
|
+
})
|
|
106
150
|
|
|
107
|
-
|
|
151
|
+
it('[SE3] [S3] full blocking requires a reference-preserving validator', () => {
|
|
152
|
+
// a validator that returns a clone defeats the reference-equality skip
|
|
153
|
+
const CloningBook = createRecordType<Book>('book', {
|
|
154
|
+
validator: { validate: (book) => ({ ...(book as Book) }) },
|
|
155
|
+
scope: 'document',
|
|
108
156
|
})
|
|
157
|
+
const cloningStore = new Store<Book>({
|
|
158
|
+
props: {},
|
|
159
|
+
schema: StoreSchema.create<Book>({ book: CloningBook }),
|
|
160
|
+
})
|
|
161
|
+
cloningStore.put([book1])
|
|
162
|
+
|
|
163
|
+
cloningStore.sideEffects.registerBeforeChangeHandler('book', (prev) => prev)
|
|
164
|
+
const afterChange = vi.fn()
|
|
165
|
+
cloningStore.sideEffects.registerAfterChangeHandler('book', afterChange)
|
|
166
|
+
const listener = vi.fn()
|
|
167
|
+
cloningStore.listen(listener)
|
|
168
|
+
|
|
169
|
+
cloningStore.update(book1Id, (b) => ({ ...b, numPages: 200 }))
|
|
170
|
+
|
|
171
|
+
// the value is still blocked, and afterChange is suppressed by deep
|
|
172
|
+
// equality (AO5)...
|
|
173
|
+
expect(cloningStore.get(book1Id)).toEqual(book1)
|
|
174
|
+
expect(afterChange).not.toHaveBeenCalled()
|
|
175
|
+
// ...but a (no-op) history entry is recorded: not a complete no-op
|
|
176
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
109
177
|
})
|
|
110
178
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const record = TestRecord.create({ name: 'test', value: 42 })
|
|
114
|
-
store.put([record])
|
|
179
|
+
it('[SE4] beforeDelete can return false to prevent that deletion only', () => {
|
|
180
|
+
store.put([book1, book2])
|
|
115
181
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
182
|
+
store.sideEffects.registerBeforeDeleteHandler('book', (record) => {
|
|
183
|
+
if (record.id === book1Id) return false
|
|
184
|
+
return undefined
|
|
185
|
+
})
|
|
186
|
+
const afterDelete = vi.fn()
|
|
187
|
+
store.sideEffects.registerAfterDeleteHandler('book', afterDelete)
|
|
119
188
|
|
|
120
|
-
|
|
121
|
-
handlerCalled = true
|
|
122
|
-
capturedPrev = prev
|
|
123
|
-
capturedNext = next
|
|
124
|
-
expect(source).toBe('user')
|
|
125
|
-
})
|
|
189
|
+
store.remove([book1Id, book2Id])
|
|
126
190
|
|
|
127
|
-
|
|
191
|
+
expect(store.has(book1Id)).toBe(true)
|
|
192
|
+
expect(store.has(book2Id)).toBe(false)
|
|
193
|
+
expect(afterDelete).toHaveBeenCalledTimes(1)
|
|
194
|
+
expect(afterDelete.mock.calls[0][0].id).toBe(book2Id)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('[SE5] after handlers observe the final state of the operation', () => {
|
|
198
|
+
const onAfterCreate = vi.fn((current: Author) => {
|
|
199
|
+
expect(current).toEqual(
|
|
200
|
+
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
|
|
201
|
+
)
|
|
202
|
+
expect(store.get(Author.createId('tolkein'))).toEqual(current)
|
|
203
|
+
})
|
|
204
|
+
store.sideEffects.registerAfterCreateHandler('author', onAfterCreate)
|
|
205
|
+
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
|
|
206
|
+
expect(onAfterCreate).toHaveBeenCalledTimes(1)
|
|
207
|
+
|
|
208
|
+
const onAfterChange = vi.fn((prev: Author, current: Author) => {
|
|
209
|
+
expect(prev.name).toBe('J.R.R Tolkein')
|
|
210
|
+
expect(current.name).toBe('Butch Cassidy')
|
|
211
|
+
expect(store.get(Author.createId('tolkein'))!.name).toBe('Butch Cassidy')
|
|
212
|
+
})
|
|
213
|
+
store.sideEffects.registerAfterChangeHandler('author', onAfterChange)
|
|
214
|
+
store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Butch Cassidy' }))
|
|
215
|
+
expect(onAfterChange).toHaveBeenCalledTimes(1)
|
|
128
216
|
|
|
129
|
-
|
|
130
|
-
expect(
|
|
131
|
-
expect(
|
|
217
|
+
const onAfterDelete = vi.fn((prev: Author) => {
|
|
218
|
+
expect(prev.name).toBe('Butch Cassidy')
|
|
219
|
+
expect(store.has(Author.createId('tolkein'))).toBe(false)
|
|
132
220
|
})
|
|
221
|
+
store.sideEffects.registerAfterDeleteHandler('author', onAfterDelete)
|
|
222
|
+
store.remove([Author.createId('tolkein')])
|
|
223
|
+
expect(onAfterDelete).toHaveBeenCalledTimes(1)
|
|
133
224
|
})
|
|
134
225
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
226
|
+
it('[SE5] handlers receive the source', () => {
|
|
227
|
+
const sources: string[] = []
|
|
228
|
+
store.sideEffects.registerAfterCreateHandler('book', (_record, source) => {
|
|
229
|
+
sources.push(source)
|
|
230
|
+
})
|
|
139
231
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
232
|
+
store.put([book1])
|
|
233
|
+
store.mergeRemoteChanges(() => {
|
|
234
|
+
store.put([book2])
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
expect(sources).toEqual(['user', 'remote'])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('[SE6] side effects disabled for an operation bypass all handlers', () => {
|
|
241
|
+
const beforeCreate = vi.fn((r: Book) => ({ ...r, title: 'transformed' }))
|
|
242
|
+
const afterCreate = vi.fn()
|
|
243
|
+
const beforeDelete = vi.fn(() => false as const)
|
|
244
|
+
const afterDelete = vi.fn()
|
|
245
|
+
const operationComplete = vi.fn()
|
|
246
|
+
|
|
247
|
+
store.sideEffects.registerBeforeCreateHandler('book', beforeCreate)
|
|
248
|
+
store.sideEffects.registerAfterCreateHandler('book', afterCreate)
|
|
249
|
+
store.sideEffects.registerBeforeDeleteHandler('book', beforeDelete)
|
|
250
|
+
store.sideEffects.registerAfterDeleteHandler('book', afterDelete)
|
|
251
|
+
store.sideEffects.registerOperationCompleteHandler(operationComplete)
|
|
252
|
+
|
|
253
|
+
store.atomic(() => {
|
|
254
|
+
store.put([book1])
|
|
255
|
+
}, false)
|
|
256
|
+
expect(store.get(book1Id)!.title).toBe(book1.title) // not transformed
|
|
257
|
+
|
|
258
|
+
store.atomic(() => {
|
|
259
|
+
store.remove([book1Id])
|
|
260
|
+
}, false)
|
|
261
|
+
expect(store.has(book1Id)).toBe(false) // beforeDelete could not block
|
|
262
|
+
|
|
263
|
+
expect(beforeCreate).not.toHaveBeenCalled()
|
|
264
|
+
expect(afterCreate).not.toHaveBeenCalled()
|
|
265
|
+
expect(beforeDelete).not.toHaveBeenCalled()
|
|
266
|
+
expect(afterDelete).not.toHaveBeenCalled()
|
|
267
|
+
expect(operationComplete).not.toHaveBeenCalled()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('[SE6] setIsEnabled(false) keeps handlers off across standalone mutations', () => {
|
|
271
|
+
const beforeCreate = vi.fn((r: Book) => ({ ...r, title: 'transformed' }))
|
|
272
|
+
const afterCreate = vi.fn()
|
|
273
|
+
store.sideEffects.registerBeforeCreateHandler('book', beforeCreate)
|
|
274
|
+
store.sideEffects.registerAfterCreateHandler('book', afterCreate)
|
|
275
|
+
|
|
276
|
+
store.sideEffects.setIsEnabled(false)
|
|
277
|
+
store.put([book1])
|
|
278
|
+
|
|
279
|
+
expect(beforeCreate).not.toHaveBeenCalled()
|
|
280
|
+
expect(afterCreate).not.toHaveBeenCalled()
|
|
281
|
+
expect(store.get(book1Id)!.title).toBe(book1.title)
|
|
282
|
+
|
|
283
|
+
// a nested attempt to switch them back on has no effect either
|
|
284
|
+
store.atomic(() => {
|
|
285
|
+
store.put([book2])
|
|
286
|
+
}, true)
|
|
287
|
+
expect(afterCreate).not.toHaveBeenCalled()
|
|
288
|
+
|
|
289
|
+
store.sideEffects.setIsEnabled(true)
|
|
290
|
+
store.remove([book1Id, book2Id])
|
|
291
|
+
store.put([book1])
|
|
292
|
+
expect(afterCreate).toHaveBeenCalledTimes(1)
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
describe('atomic operations (AO)', () => {
|
|
297
|
+
let callbacks: any[] = []
|
|
298
|
+
|
|
299
|
+
let onAfterCreate: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
|
300
|
+
let onAfterChange: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
|
301
|
+
let onAfterDelete: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
|
302
|
+
|
|
303
|
+
let onBeforeCreate: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
|
304
|
+
let onBeforeChange: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
|
305
|
+
let onBeforeDelete: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
|
306
|
+
|
|
307
|
+
let onOperationComplete: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
|
308
|
+
|
|
309
|
+
beforeEach(() => {
|
|
310
|
+
onAfterCreate = vi.fn((record) => callbacks.push({ type: 'create', record }))
|
|
311
|
+
onAfterChange = vi.fn((from, to) => callbacks.push({ type: 'change', from, to }))
|
|
312
|
+
onAfterDelete = vi.fn((record) => callbacks.push({ type: 'delete', record }))
|
|
313
|
+
|
|
314
|
+
onBeforeCreate = vi.fn((record) => record)
|
|
315
|
+
onBeforeChange = vi.fn((_from, to) => to)
|
|
316
|
+
onBeforeDelete = vi.fn((_record) => {})
|
|
317
|
+
|
|
318
|
+
onOperationComplete = vi.fn(() => callbacks.push({ type: 'complete' }))
|
|
319
|
+
callbacks = []
|
|
320
|
+
|
|
321
|
+
store.sideEffects.registerAfterCreateHandler('book', onAfterCreate)
|
|
322
|
+
store.sideEffects.registerAfterChangeHandler('book', onAfterChange)
|
|
323
|
+
store.sideEffects.registerAfterDeleteHandler('book', onAfterDelete)
|
|
324
|
+
|
|
325
|
+
store.sideEffects.registerBeforeCreateHandler('book', onBeforeCreate)
|
|
326
|
+
store.sideEffects.registerBeforeChangeHandler('book', onBeforeChange)
|
|
327
|
+
store.sideEffects.registerBeforeDeleteHandler('book', onBeforeDelete)
|
|
144
328
|
|
|
145
|
-
|
|
329
|
+
store.sideEffects.registerOperationCompleteHandler(onOperationComplete)
|
|
330
|
+
})
|
|
146
331
|
|
|
147
|
-
|
|
332
|
+
it('[AO1] atomic returns the function result and applies changes as one batch to effects', () => {
|
|
333
|
+
const sizes: number[] = []
|
|
334
|
+
const stop = react('count', () => {
|
|
335
|
+
sizes.push(store.query.ids('book').get().size)
|
|
148
336
|
})
|
|
149
337
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
store.put([
|
|
338
|
+
const result = store.atomic(() => {
|
|
339
|
+
store.put([book1])
|
|
340
|
+
store.put([book2])
|
|
341
|
+
return 'completed'
|
|
342
|
+
})
|
|
153
343
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
344
|
+
expect(result).toBe('completed')
|
|
345
|
+
// the effect saw the empty store, then both books at once
|
|
346
|
+
expect(sizes).toEqual([0, 2])
|
|
347
|
+
stop()
|
|
348
|
+
})
|
|
159
349
|
|
|
160
|
-
|
|
350
|
+
it('[AO6] after callbacks fire when the outermost atomic completes', () => {
|
|
351
|
+
store.atomic(() => {
|
|
352
|
+
expect(callbacks).toHaveLength(0)
|
|
161
353
|
|
|
162
|
-
|
|
163
|
-
|
|
354
|
+
store.put([book1, book2])
|
|
355
|
+
|
|
356
|
+
expect(callbacks).toHaveLength(0)
|
|
164
357
|
})
|
|
358
|
+
|
|
359
|
+
expect(callbacks).toMatchObject([
|
|
360
|
+
{ type: 'create', record: { id: book1Id } },
|
|
361
|
+
{ type: 'create', record: { id: book2Id } },
|
|
362
|
+
{ type: 'complete' },
|
|
363
|
+
])
|
|
165
364
|
})
|
|
166
365
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
store.
|
|
366
|
+
it('[AO2] nested atomic calls fold into the outer operation', () => {
|
|
367
|
+
store.atomic(() => {
|
|
368
|
+
store.put([book1])
|
|
369
|
+
store.atomic(() => {
|
|
370
|
+
store.put([book2])
|
|
371
|
+
})
|
|
372
|
+
expect(callbacks).toHaveLength(0)
|
|
373
|
+
})
|
|
171
374
|
|
|
172
|
-
|
|
173
|
-
|
|
375
|
+
expect(callbacks).toMatchObject([
|
|
376
|
+
{ type: 'create', record: { id: book1Id } },
|
|
377
|
+
{ type: 'create', record: { id: book2Id } },
|
|
378
|
+
{ type: 'complete' },
|
|
379
|
+
])
|
|
380
|
+
})
|
|
174
381
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
382
|
+
it('[AO2] mergeRemoteChanges inside an atomic operation throws', () => {
|
|
383
|
+
expect(() => {
|
|
384
|
+
store.atomic(() => {
|
|
385
|
+
store.mergeRemoteChanges(() => {
|
|
386
|
+
// never reached
|
|
387
|
+
})
|
|
179
388
|
})
|
|
389
|
+
}).toThrow('Cannot merge remote changes while in atomic operation')
|
|
390
|
+
})
|
|
180
391
|
|
|
181
|
-
|
|
392
|
+
it('[AO3] runCallbacks: false disables the before handlers', () => {
|
|
393
|
+
store.atomic(() => {
|
|
394
|
+
store.put([book1])
|
|
395
|
+
}, false)
|
|
182
396
|
|
|
183
|
-
|
|
184
|
-
|
|
397
|
+
expect(onBeforeCreate).not.toHaveBeenCalled()
|
|
398
|
+
expect(onAfterCreate).not.toHaveBeenCalled()
|
|
399
|
+
expect(store.has(book1Id)).toBe(true)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test('[AO3] a nested atomic can switch callbacks off but not back on', () => {
|
|
403
|
+
store.atomic(() => {
|
|
404
|
+
store.put([book1])
|
|
405
|
+
store.atomic(() => {
|
|
406
|
+
store.put([book2])
|
|
407
|
+
}, false)
|
|
185
408
|
})
|
|
409
|
+
|
|
410
|
+
expect(onBeforeCreate).toHaveBeenCalledTimes(1)
|
|
411
|
+
expect(onAfterCreate).toHaveBeenCalledTimes(2)
|
|
412
|
+
|
|
413
|
+
store.atomic(() => {
|
|
414
|
+
store.update(book1Id, (book) => ({ ...book, numPages: book.numPages + 1 }))
|
|
415
|
+
store.atomic(() => {
|
|
416
|
+
store.update(book2Id, (book) => ({ ...book, numPages: book.numPages + 1 }))
|
|
417
|
+
}, false)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
expect(onBeforeChange).toHaveBeenCalledTimes(1)
|
|
421
|
+
expect(onAfterChange).toHaveBeenCalledTimes(2)
|
|
422
|
+
|
|
423
|
+
store.atomic(() => {
|
|
424
|
+
store.remove([book1Id])
|
|
425
|
+
store.atomic(() => {
|
|
426
|
+
store.remove([book2Id])
|
|
427
|
+
}, false)
|
|
428
|
+
})
|
|
429
|
+
expect(onBeforeDelete).toHaveBeenCalledTimes(1)
|
|
430
|
+
expect(onAfterDelete).toHaveBeenCalledTimes(2)
|
|
431
|
+
|
|
432
|
+
// switching ON inside an OFF block does not work
|
|
433
|
+
store.atomic(() => {
|
|
434
|
+
store.atomic(() => {
|
|
435
|
+
store.put([book1])
|
|
436
|
+
}, true)
|
|
437
|
+
}, false)
|
|
438
|
+
expect(onBeforeCreate).toHaveBeenCalledTimes(1)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('[AO4] a record created then deleted in one operation produces no callbacks', () => {
|
|
442
|
+
store.atomic(() => {
|
|
443
|
+
store.put([book1])
|
|
444
|
+
store.remove([book1Id])
|
|
445
|
+
})
|
|
446
|
+
expect(callbacks).toMatchObject([{ type: 'complete' }])
|
|
186
447
|
})
|
|
187
448
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
449
|
+
it('[AO4] a record created then updated produces one afterCreate with the final value', () => {
|
|
450
|
+
store.atomic(() => {
|
|
451
|
+
store.put([book1])
|
|
452
|
+
store.update(book1Id, (b) => ({ ...b, numPages: 99 }))
|
|
453
|
+
})
|
|
192
454
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
455
|
+
expect(onAfterCreate).toHaveBeenCalledTimes(1)
|
|
456
|
+
expect(onAfterChange).not.toHaveBeenCalled()
|
|
457
|
+
expect(onAfterCreate.mock.calls[0][0].numPages).toBe(99)
|
|
458
|
+
})
|
|
197
459
|
|
|
198
|
-
|
|
199
|
-
|
|
460
|
+
it('[AO4] a record updated repeatedly produces one afterChange spanning first to last', () => {
|
|
461
|
+
store.put([book1])
|
|
462
|
+
callbacks = []
|
|
200
463
|
|
|
201
|
-
|
|
202
|
-
|
|
464
|
+
store.atomic(() => {
|
|
465
|
+
store.update(book1Id, (b) => ({ ...b, numPages: 2 }))
|
|
466
|
+
store.update(book1Id, (b) => ({ ...b, numPages: 3 }))
|
|
467
|
+
store.update(book1Id, (b) => ({ ...b, numPages: 4 }))
|
|
203
468
|
})
|
|
469
|
+
|
|
470
|
+
expect(onAfterChange).toHaveBeenCalledTimes(1)
|
|
471
|
+
expect(onAfterChange.mock.calls[0][0].numPages).toBe(1) // before
|
|
472
|
+
expect(onAfterChange.mock.calls[0][1].numPages).toBe(4) // after
|
|
204
473
|
})
|
|
205
474
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
475
|
+
test('[AO5] a round trip back to a deep-equal value does not fire afterChange', () => {
|
|
476
|
+
const book1A = book1
|
|
477
|
+
const book1B = { ...book1, title: book1.title + ' is a really great book fr fr' }
|
|
478
|
+
const book1C = structuredClone(book1A)
|
|
479
|
+
store.put([book1A])
|
|
480
|
+
|
|
481
|
+
store.atomic(() => {
|
|
482
|
+
store.put([book1B])
|
|
483
|
+
store.put([book1C])
|
|
484
|
+
})
|
|
485
|
+
expect(onAfterChange).toHaveBeenCalledTimes(0)
|
|
213
486
|
|
|
214
|
-
|
|
487
|
+
store.atomic(() => {
|
|
488
|
+
store.put([book1B])
|
|
489
|
+
store.put([book1C])
|
|
490
|
+
store.put([book1B])
|
|
491
|
+
})
|
|
215
492
|
|
|
216
|
-
|
|
217
|
-
|
|
493
|
+
expect(onAfterChange).toHaveBeenCalledTimes(1)
|
|
494
|
+
})
|
|
218
495
|
|
|
219
|
-
|
|
220
|
-
|
|
496
|
+
it('[AO6] changes made by after handlers run another round, with a depth limit', () => {
|
|
497
|
+
let limit = 10
|
|
498
|
+
onAfterCreate.mockImplementation((record: any) => {
|
|
499
|
+
if (record.numPages < limit) {
|
|
500
|
+
store.put([{ ...record, numPages: record.numPages + 1 }])
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
onAfterChange.mockImplementation((_from: any, to: any) => {
|
|
504
|
+
if (to.numPages < limit) {
|
|
505
|
+
store.put([{ ...to, numPages: to.numPages + 1 }])
|
|
506
|
+
}
|
|
221
507
|
})
|
|
508
|
+
|
|
509
|
+
// this should be fine:
|
|
510
|
+
store.put([book1])
|
|
511
|
+
expect(store.get(book1Id)!.numPages).toBe(limit)
|
|
512
|
+
|
|
513
|
+
// if we increase the limit, it should bail out:
|
|
514
|
+
limit = 10000
|
|
515
|
+
store.clear()
|
|
516
|
+
expect(() => {
|
|
517
|
+
store.put([book2])
|
|
518
|
+
}).toThrowErrorMatchingInlineSnapshot(
|
|
519
|
+
`[Error: Maximum store update depth exceeded, bailing out]`
|
|
520
|
+
)
|
|
222
521
|
})
|
|
223
522
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
523
|
+
it('[AO7] operationComplete keeps firing until the operation quiesces', () => {
|
|
524
|
+
// steps:
|
|
525
|
+
// 0, 1, 2: after change increment pages
|
|
526
|
+
// 3: after change, do nothing
|
|
527
|
+
// 4: operation complete, increment pages by 1000
|
|
528
|
+
// 5, 6: after change increment pages
|
|
529
|
+
// 7: after change, do nothing
|
|
530
|
+
// 8: operation complete, do nothing
|
|
531
|
+
// 9: done!
|
|
532
|
+
let step = 0
|
|
533
|
+
|
|
534
|
+
store.put([book1])
|
|
535
|
+
|
|
536
|
+
onAfterChange.mockImplementation((_prev: any, next: any) => {
|
|
537
|
+
if ([0, 1, 2, 5, 6].includes(step)) {
|
|
538
|
+
step++
|
|
539
|
+
store.put([{ ...next, numPages: next.numPages + 1 }])
|
|
540
|
+
} else if ([3, 7].includes(step)) {
|
|
541
|
+
step++
|
|
542
|
+
} else {
|
|
543
|
+
throw new Error(`Wrong step: ${step}`)
|
|
544
|
+
}
|
|
545
|
+
})
|
|
227
546
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
547
|
+
onOperationComplete.mockImplementation(() => {
|
|
548
|
+
if (step === 4) {
|
|
549
|
+
step++
|
|
550
|
+
const book = store.get(book1Id)!
|
|
551
|
+
store.put([{ ...book, numPages: book.numPages + 1000 }])
|
|
552
|
+
} else if (step === 8) {
|
|
553
|
+
step++
|
|
554
|
+
} else {
|
|
555
|
+
throw new Error(`Wrong step: ${step}`)
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
store.put([{ ...book1, numPages: 2 }])
|
|
560
|
+
|
|
561
|
+
expect(store.get(book1Id)!.numPages).toBe(1007)
|
|
562
|
+
expect(step).toBe(9)
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('[AO8] nested mergeRemoteChanges just runs its function', () => {
|
|
566
|
+
const sources: string[] = []
|
|
567
|
+
store.sideEffects.registerAfterCreateHandler('book', (_r, source) => sources.push(source))
|
|
231
568
|
|
|
232
|
-
|
|
569
|
+
store.mergeRemoteChanges(() => {
|
|
570
|
+
store.put([book1])
|
|
233
571
|
store.mergeRemoteChanges(() => {
|
|
234
|
-
store.put([
|
|
572
|
+
store.put([book2])
|
|
235
573
|
})
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
expect(sources).toEqual(['remote', 'remote'])
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
test('[AO8] side-effect cascades from remote changes are attributed to user', () => {
|
|
580
|
+
const diffs: HistoryEntry<LibraryType>[] = []
|
|
581
|
+
store.listen((entry) => {
|
|
582
|
+
diffs.push(entry)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
const firstOrderEffectSources: string[] = []
|
|
586
|
+
store.sideEffects.registerAfterCreateHandler('book', (record, source) => {
|
|
587
|
+
firstOrderEffectSources.push(source)
|
|
588
|
+
if (record.title.startsWith('Harry Potter')) {
|
|
589
|
+
store.put([{ ...record, title: record.title + ' is a really great book fr fr' }])
|
|
590
|
+
}
|
|
591
|
+
})
|
|
236
592
|
|
|
237
|
-
|
|
593
|
+
const secondOrderEffectSources: string[] = []
|
|
594
|
+
store.sideEffects.registerAfterChangeHandler('book', (_from, _to, source) => {
|
|
595
|
+
secondOrderEffectSources.push(source)
|
|
238
596
|
})
|
|
597
|
+
|
|
598
|
+
store.mergeRemoteChanges(() => {
|
|
599
|
+
store.put([{ ...book1, title: "Harry Potter and the Philosopher's Stone" }])
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
expect(firstOrderEffectSources).toEqual(['remote'])
|
|
603
|
+
// recursive changes are always user
|
|
604
|
+
expect(secondOrderEffectSources).toEqual(['user'])
|
|
605
|
+
|
|
606
|
+
expect(diffs).toMatchObject([
|
|
607
|
+
{
|
|
608
|
+
source: 'remote',
|
|
609
|
+
changes: {
|
|
610
|
+
added: { [book1Id]: { title: "Harry Potter and the Philosopher's Stone" } },
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
source: 'user',
|
|
615
|
+
changes: {
|
|
616
|
+
updated: {
|
|
617
|
+
[book1Id]: [
|
|
618
|
+
{ title: "Harry Potter and the Philosopher's Stone" },
|
|
619
|
+
{
|
|
620
|
+
title: "Harry Potter and the Philosopher's Stone is a really great book fr fr",
|
|
621
|
+
},
|
|
622
|
+
],
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
])
|
|
239
627
|
})
|
|
240
628
|
})
|