@tldraw/store 5.2.0-next.ee0fa4d6244f → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/DOCS.md +790 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/ImmutableMap.js +0 -13
  5. package/dist-cjs/lib/ImmutableMap.js.map +2 -2
  6. package/dist-cjs/lib/RecordType.js +1 -1
  7. package/dist-cjs/lib/RecordType.js.map +2 -2
  8. package/dist-cjs/lib/RecordsDiff.js +26 -14
  9. package/dist-cjs/lib/RecordsDiff.js.map +2 -2
  10. package/dist-cjs/lib/Store.js +6 -5
  11. package/dist-cjs/lib/Store.js.map +2 -2
  12. package/dist-cjs/lib/StoreSchema.js +1 -1
  13. package/dist-cjs/lib/StoreSchema.js.map +2 -2
  14. package/dist-cjs/lib/executeQuery.js +1 -1
  15. package/dist-cjs/lib/executeQuery.js.map +2 -2
  16. package/dist-esm/index.mjs +1 -1
  17. package/dist-esm/lib/ImmutableMap.mjs +0 -13
  18. package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
  19. package/dist-esm/lib/RecordType.mjs +1 -1
  20. package/dist-esm/lib/RecordType.mjs.map +2 -2
  21. package/dist-esm/lib/RecordsDiff.mjs +26 -14
  22. package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
  23. package/dist-esm/lib/Store.mjs +7 -6
  24. package/dist-esm/lib/Store.mjs.map +2 -2
  25. package/dist-esm/lib/StoreSchema.mjs +1 -1
  26. package/dist-esm/lib/StoreSchema.mjs.map +2 -2
  27. package/dist-esm/lib/executeQuery.mjs +1 -1
  28. package/dist-esm/lib/executeQuery.mjs.map +2 -2
  29. package/package.json +9 -5
  30. package/src/lib/{test/AtomMap.test.ts → AtomMap.test.ts} +77 -111
  31. package/src/lib/AtomSet.test.ts +116 -0
  32. package/src/lib/BaseRecord.test.ts +10 -22
  33. package/src/lib/ImmutableMap.test.ts +114 -71
  34. package/src/lib/ImmutableMap.ts +1 -0
  35. package/src/lib/IncrementalSetConstructor.test.ts +76 -81
  36. package/src/lib/RecordType.test.ts +216 -0
  37. package/src/lib/RecordType.ts +1 -1
  38. package/src/lib/RecordsDiff.test.ts +112 -106
  39. package/src/lib/RecordsDiff.ts +43 -18
  40. package/src/lib/Store.test.ts +570 -630
  41. package/src/lib/Store.ts +9 -10
  42. package/src/lib/StoreListeners.test.ts +462 -0
  43. package/src/lib/StoreQueries.test.ts +586 -434
  44. package/src/lib/StoreSchema.test.ts +1012 -174
  45. package/src/lib/StoreSchema.ts +1 -1
  46. package/src/lib/StoreSideEffects.test.ts +546 -158
  47. package/src/lib/devFreeze.test.ts +94 -124
  48. package/src/lib/executeQuery.test.ts +77 -31
  49. package/src/lib/executeQuery.ts +3 -1
  50. package/src/lib/migrate.test.ts +273 -296
  51. package/src/lib/setUtils.test.ts +38 -79
  52. package/src/lib/test/createMigrations.test.ts +0 -75
  53. package/src/lib/test/dependsOn.test.ts +0 -166
  54. package/src/lib/test/getMigrationsSince.test.ts +0 -121
  55. package/src/lib/test/migrate.test.ts +0 -118
  56. package/src/lib/test/migratePersistedRecord.test.ts +0 -265
  57. package/src/lib/test/migrationCaching.test.ts +0 -209
  58. package/src/lib/test/recordStore.test.ts +0 -1567
  59. package/src/lib/test/recordStoreQueries.test.ts +0 -814
  60. package/src/lib/test/recordType.test.ts +0 -19
  61. package/src/lib/test/sortMigrations.test.ts +0 -83
  62. package/src/lib/test/upgradeSchema.test.ts +0 -80
  63. package/src/lib/test/validate.test.ts +0 -178
  64. package/src/lib/test/validateMigrations.test.ts +0 -165
@@ -1,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
- interface TestRecord extends BaseRecord<'test', RecordId<TestRecord>> {
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 TestRecord = createRecordType<TestRecord>('test', {
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
- }).withDefaultProperties(() => ({
17
- name: '',
18
- value: 0,
19
- }))
28
+ })
20
29
 
21
- describe('StoreSideEffects', () => {
22
- let store: Store<TestRecord>
30
+ type LibraryType = Book | Author
23
31
 
24
- beforeEach(() => {
25
- const schema = StoreSchema.create<TestRecord>({ test: TestRecord })
26
- store = new Store({ schema, props: {} })
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
- describe('beforeCreate handlers', () => {
30
- it('calls beforeCreate handlers and allows transformation', () => {
31
- let handlerCalled = false
32
- store.sideEffects.registerBeforeCreateHandler('test', (record, source) => {
33
- handlerCalled = true
34
- expect(source).toBe('user')
35
- return { ...record, name: 'transformed' }
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
- const record = TestRecord.create({ name: 'original', value: 42 })
39
- store.put([record])
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
- expect(handlerCalled).toBe(true)
42
- expect(store.get(record.id)!.name).toBe('transformed')
43
- })
66
+ cleanup()
67
+ store.put([book1])
44
68
 
45
- it('chains multiple beforeCreate handlers', () => {
46
- store.sideEffects.registerBeforeCreateHandler('test', (record) => ({
47
- ...record,
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
- const record = TestRecord.create({ name: 'test', value: 5 })
56
- store.put([record])
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
- // First handler: 5 + 10 = 15, Second handler: 15 * 2 = 30
59
- expect(store.get(record.id)!.value).toBe(30)
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
- describe('afterCreate handlers', () => {
64
- it('calls afterCreate handlers after record creation', () => {
65
- let handlerCalled = false
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
- const record = TestRecord.create({ name: 'test', value: 42 })
74
- store.put([record])
107
+ store.put([Author.create({ name: 'Ursula K. Le Guin' })])
108
+ expect(bookHandler).not.toHaveBeenCalled()
109
+ })
75
110
 
76
- expect(handlerCalled).toBe(true)
77
- expect(capturedRecord).toEqual(record)
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
- describe('beforeChange handlers', () => {
82
- it('allows transformation of changes', () => {
83
- const record = TestRecord.create({ name: 'test', value: 42 })
84
- store.put([record])
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
- store.sideEffects.registerBeforeChangeHandler('test', (prev, next) => ({
87
- ...next,
88
- name: next.name + '_transformed',
89
- }))
131
+ expect(store.get(book1Id)!.title).toBe('updated_transformed')
132
+ })
90
133
 
91
- store.update(record.id, (r) => ({ ...r, name: 'updated' }))
134
+ it('[SE3] returning prev from beforeChange blocks the update entirely', () => {
135
+ store.put([book1])
92
136
 
93
- expect(store.get(record.id)!.name).toBe('updated_transformed')
137
+ store.sideEffects.registerBeforeChangeHandler('book', (prev, next) => {
138
+ if (next.numPages > 100) return prev
139
+ return next
94
140
  })
95
141
 
96
- it('can prevent changes by returning previous record', () => {
97
- const record = TestRecord.create({ name: 'test', value: 42 })
98
- store.put([record])
142
+ const listener = vi.fn()
143
+ store.listen(listener)
99
144
 
100
- store.sideEffects.registerBeforeChangeHandler('test', (prev, next) => {
101
- if (next.value > 100) return prev
102
- return next
103
- })
145
+ store.update(book1Id, (b) => ({ ...b, numPages: 200 }))
104
146
 
105
- store.update(record.id, (r) => ({ ...r, value: 200 }))
147
+ expect(store.get(book1Id)!.numPages).toBe(1) // unchanged
148
+ expect(listener).not.toHaveBeenCalled() // no history entry either
149
+ })
106
150
 
107
- expect(store.get(record.id)!.value).toBe(42) // unchanged
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
- describe('afterChange handlers', () => {
112
- it('calls afterChange handlers with previous and current records', () => {
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
- let handlerCalled = false
117
- let capturedPrev: TestRecord | null = null
118
- let capturedNext: TestRecord | null = null
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
- store.sideEffects.registerAfterChangeHandler('test', (prev, next, source) => {
121
- handlerCalled = true
122
- capturedPrev = prev
123
- capturedNext = next
124
- expect(source).toBe('user')
125
- })
189
+ store.remove([book1Id, book2Id])
126
190
 
127
- store.update(record.id, (r) => ({ ...r, value: 100 }))
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
- expect(handlerCalled).toBe(true)
130
- expect(capturedPrev!.value).toBe(42)
131
- expect(capturedNext!.value).toBe(100)
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
- describe('beforeDelete handlers', () => {
136
- it('can prevent deletion by returning false', () => {
137
- const record = TestRecord.create({ name: 'protected', value: 42 })
138
- store.put([record])
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
- store.sideEffects.registerBeforeDeleteHandler('test', (record) => {
141
- if (record.name === 'protected') return false
142
- return
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
- store.remove([record.id])
329
+ store.sideEffects.registerOperationCompleteHandler(onOperationComplete)
330
+ })
146
331
 
147
- expect(store.get(record.id)).toBeDefined() // still exists
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
- it('allows deletion when handler returns void', () => {
151
- const record = TestRecord.create({ name: 'deletable', value: 42 })
152
- store.put([record])
338
+ const result = store.atomic(() => {
339
+ store.put([book1])
340
+ store.put([book2])
341
+ return 'completed'
342
+ })
153
343
 
154
- let handlerCalled = false
155
- store.sideEffects.registerBeforeDeleteHandler('test', (_record) => {
156
- handlerCalled = true
157
- return // void return allows deletion
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
- store.remove([record.id])
350
+ it('[AO6] after callbacks fire when the outermost atomic completes', () => {
351
+ store.atomic(() => {
352
+ expect(callbacks).toHaveLength(0)
161
353
 
162
- expect(handlerCalled).toBe(true)
163
- expect(store.get(record.id)).toBeUndefined()
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
- describe('afterDelete handlers', () => {
168
- it('calls afterDelete handlers with deleted record', () => {
169
- const record = TestRecord.create({ name: 'test', value: 42 })
170
- store.put([record])
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
- let handlerCalled = false
173
- let capturedRecord: TestRecord | null = null
375
+ expect(callbacks).toMatchObject([
376
+ { type: 'create', record: { id: book1Id } },
377
+ { type: 'create', record: { id: book2Id } },
378
+ { type: 'complete' },
379
+ ])
380
+ })
174
381
 
175
- store.sideEffects.registerAfterDeleteHandler('test', (record, source) => {
176
- handlerCalled = true
177
- capturedRecord = record
178
- expect(source).toBe('user')
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
- store.remove([record.id])
392
+ it('[AO3] runCallbacks: false disables the before handlers', () => {
393
+ store.atomic(() => {
394
+ store.put([book1])
395
+ }, false)
182
396
 
183
- expect(handlerCalled).toBe(true)
184
- expect(capturedRecord).toEqual(record)
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
- describe('operationComplete handlers', () => {
189
- it('calls handler after operation completes', () => {
190
- let handlerCalled = false
191
- let capturedSource: 'user' | 'remote' | null = null
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
- store.sideEffects.registerOperationCompleteHandler((source) => {
194
- handlerCalled = true
195
- capturedSource = source
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
- const record = TestRecord.create({ name: 'test', value: 42 })
199
- store.put([record])
460
+ it('[AO4] a record updated repeatedly produces one afterChange spanning first to last', () => {
461
+ store.put([book1])
462
+ callbacks = []
200
463
 
201
- expect(handlerCalled).toBe(true)
202
- expect(capturedSource).toBe('user')
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
- describe('handler cleanup', () => {
207
- it('removes handlers when cleanup function is called', () => {
208
- let handlerCalled = false
209
- const cleanup = store.sideEffects.registerBeforeCreateHandler('test', (record) => {
210
- handlerCalled = true
211
- return { ...record, name: 'transformed' }
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
- cleanup()
487
+ store.atomic(() => {
488
+ store.put([book1B])
489
+ store.put([book1C])
490
+ store.put([book1B])
491
+ })
215
492
 
216
- const record = TestRecord.create({ name: 'original', value: 42 })
217
- store.put([record])
493
+ expect(onAfterChange).toHaveBeenCalledTimes(1)
494
+ })
218
495
 
219
- expect(handlerCalled).toBe(false)
220
- expect(store.get(record.id)!.name).toBe('original')
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
- describe('source parameter handling', () => {
225
- it('passes correct source to handlers for remote changes', () => {
226
- let capturedSource: 'user' | 'remote' | null = null
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
- store.sideEffects.registerAfterCreateHandler('test', (_, source) => {
229
- capturedSource = source
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
- const record = TestRecord.create({ name: 'test', value: 42 })
569
+ store.mergeRemoteChanges(() => {
570
+ store.put([book1])
233
571
  store.mergeRemoteChanges(() => {
234
- store.put([record])
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
- expect(capturedSource).toBe('remote')
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
  })