@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,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 { RecordsDiff } from './RecordsDiff'
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
- // Test record types
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: (record: unknown): Book => {
20
- if (!record || typeof record !== 'object') {
21
- throw new Error('Book record must be an object')
22
- }
23
- const r = record as any
24
- if (typeof r.id !== 'string' || !r.id.startsWith('book:')) {
25
- throw new Error('Book must have valid id starting with "book:"')
26
- }
27
- if (r.typeName !== 'book') {
28
- throw new Error('Book typeName must be "book"')
29
- }
30
- if (typeof r.title !== 'string' || r.title.trim().length === 0) {
31
- throw new Error('Book must have non-empty title string')
32
- }
33
- if (typeof r.author !== 'string' || !r.author.startsWith('author:')) {
34
- throw new Error('Book must have valid author RecordId')
35
- }
36
- if (typeof r.numPages !== 'number' || r.numPages < 1) {
37
- throw new Error('Book numPages must be positive number')
38
- }
39
- if (r.genre !== undefined && typeof r.genre !== 'string') {
40
- throw new Error('Book genre must be string if provided')
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
- if (r.inStock !== undefined && typeof r.inStock !== 'boolean') {
43
- throw new Error('Book inStock must be boolean if provided')
44
- }
45
- return r as Book
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: (record: unknown): Author => {
63
- if (!record || typeof record !== 'object') {
64
- throw new Error('Author record must be an object')
65
- }
66
- const r = record as any
67
- if (typeof r.id !== 'string' || !r.id.startsWith('author:')) {
68
- throw new Error('Author must have valid id starting with "author:"')
69
- }
70
- if (r.typeName !== 'author') {
71
- throw new Error('Author typeName must be "author"')
72
- }
73
- if (typeof r.name !== 'string' || r.name.trim().length === 0) {
74
- throw new Error('Author must have non-empty name string')
75
- }
76
- if (typeof r.isPseudonym !== 'boolean') {
77
- throw new Error('Author isPseudonym must be boolean')
78
- }
79
- if (
80
- r.birthYear !== undefined &&
81
- (typeof r.birthYear !== 'number' || r.birthYear < 1000 || r.birthYear > 2100)
82
- ) {
83
- throw new Error('Author birthYear must be reasonable year number if provided')
84
- }
85
- return r as Author
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
- describe('Store', () => {
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
- describe('basic record operations', () => {
192
- it('puts records into the store', () => {
193
- const author = Author.create({ name: 'J.R.R. Tolkien' })
194
- const book = Book.create({
195
- title: 'The Hobbit',
196
- author: author.id,
197
- numPages: 310,
198
- })
199
-
200
- store.put([author, book])
91
+ it('[S1] a new store is empty', () => {
92
+ expect(store.allRecords()).toEqual([])
93
+ })
201
94
 
202
- expect(store.get(author.id)).toEqual(author)
203
- expect(store.get(book.id)).toEqual(book)
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
- it('updates existing records', () => {
207
- const author = Author.create({ name: 'J.R.R. Tolkien' })
208
- store.put([author])
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
- const updatedAuthor = { ...author, name: 'John Ronald Reuel Tolkien' }
211
- store.put([updatedAuthor])
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
- expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien')
214
- })
116
+ store.put([author, book])
215
117
 
216
- it('removes records from the store', () => {
217
- const author = Author.create({ name: 'J.R.R. Tolkien' })
218
- const book = Book.create({
219
- title: 'The Hobbit',
220
- author: author.id,
221
- numPages: 310,
222
- })
223
-
224
- store.put([author, book])
225
- expect(store.has(author.id)).toBe(true)
226
- expect(store.has(book.id)).toBe(true)
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
- store.remove([author.id, book.id])
229
- expect(store.has(author.id)).toBe(false)
230
- expect(store.has(book.id)).toBe(false)
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
- it('updates records using update method', () => {
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
- store.update(author.id, (current) => ({
238
- ...current,
239
- name: 'John Ronald Reuel Tolkien',
240
- }))
131
+ expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien')
132
+ })
241
133
 
242
- expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien')
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
- it('clears all records', () => {
246
- const author = Author.create({ name: 'J.R.R. Tolkien' })
247
- const book = Book.create({
248
- title: 'The Hobbit',
249
- author: author.id,
250
- numPages: 310,
251
- })
138
+ const listener = vi.fn()
139
+ store.listen(listener)
252
140
 
253
- store.put([author, book])
254
- expect(store.allRecords()).toHaveLength(2)
141
+ store.update(author.id, (a) => a)
142
+ store.put([store.get(author.id)!])
255
143
 
256
- store.clear()
257
- expect(store.allRecords()).toHaveLength(0)
258
- })
144
+ expect(listener).not.toHaveBeenCalled()
259
145
  })
260
146
 
261
- describe('atomic operations', () => {
262
- it('performs atomic operations', () => {
263
- const author = Author.create({ name: 'J.R.R. Tolkien' })
264
- const book = Book.create({
265
- title: 'The Hobbit',
266
- author: author.id,
267
- numPages: 310,
268
- })
269
-
270
- const result = store.atomic(() => {
271
- store.put([author])
272
- store.put([book])
273
- return 'completed'
274
- })
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
- expect(result).toBe('completed')
277
- expect(store.get(author.id)).toEqual(author)
278
- expect(store.get(book.id)).toEqual(book)
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
- it('handles nested atomic operations', () => {
282
- const author = Author.create({ name: 'J.R.R. Tolkien' })
283
- const book = Book.create({
284
- title: 'The Hobbit',
285
- author: author.id,
286
- numPages: 310,
287
- })
288
-
289
- store.atomic(() => {
290
- store.put([author])
291
- store.atomic(() => {
292
- store.put([book])
293
- })
294
- })
295
-
296
- expect(store.get(author.id)).toEqual(author)
297
- expect(store.get(book.id)).toEqual(book)
298
- })
156
+ expect(Object.keys(diff.updated)).toEqual([])
157
+ expect(Object.keys(diff.added)).toEqual([other.id])
299
158
  })
300
159
 
301
- describe('history tracking', () => {
302
- it('extracts changes from operations', () => {
303
- const author = Author.create({ name: 'J.R.R. Tolkien' })
304
- const book = Book.create({
305
- title: 'The Hobbit',
306
- author: author.id,
307
- numPages: 310,
308
- })
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
- const changes = store.extractingChanges(() => {
311
- store.put([author, book])
312
- })
164
+ store.update(author.id, (current) => ({ ...current, name: 'Jimmy Tolks' }))
313
165
 
314
- expect(changes.added).toHaveProperty(author.id)
315
- expect(changes.added).toHaveProperty(book.id)
316
- expect(Object.keys(changes.updated)).toHaveLength(0)
317
- expect(Object.keys(changes.removed)).toHaveLength(0)
318
- })
166
+ expect(store.get(author.id)?.name).toBe('Jimmy Tolks')
167
+ })
319
168
 
320
- it('extracts update changes', () => {
321
- const author = Author.create({ name: 'J.R.R. Tolkien' })
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
- const changes = store.extractingChanges(() => {
325
- store.update(author.id, (a) => ({ ...a, name: 'John Ronald Reuel Tolkien' }))
326
- })
172
+ expect(() => {
173
+ store.update(Author.createId('missing'), (a) => a)
174
+ }).not.toThrow()
327
175
 
328
- expect(Object.keys(changes.added)).toHaveLength(0)
329
- expect(changes.updated).toHaveProperty(author.id)
330
- expect(Object.keys(changes.removed)).toHaveLength(0)
331
- })
176
+ expect(consoleSpy).toHaveBeenCalled()
177
+ consoleSpy.mockRestore()
178
+ })
332
179
 
333
- it('extracts removal changes', () => {
334
- const author = Author.create({ name: 'J.R.R. Tolkien' })
335
- store.put([author])
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
- const changes = store.extractingChanges(() => {
338
- store.remove([author.id])
339
- })
185
+ store.remove([author.id, Book.createId('missing')])
340
186
 
341
- expect(Object.keys(changes.added)).toHaveLength(0)
342
- expect(Object.keys(changes.updated)).toHaveLength(0)
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
- describe('listeners', () => {
348
- it('adds and removes listeners', async () => {
349
- const listener = vi.fn()
350
- const removeListener = store.listen(listener)
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
- const author = Author.create({ name: 'J.R.R. Tolkien' })
353
- store.put([author])
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
- // Wait for async history flush
356
- await new Promise((resolve) => requestAnimationFrame(resolve))
202
+ store.clear()
203
+ expect(store.allRecords()).toHaveLength(0)
204
+ })
357
205
 
358
- expect(listener).toHaveBeenCalledTimes(1)
359
- expect(listener).toHaveBeenCalledWith(
360
- expect.objectContaining({
361
- source: 'user',
362
- changes: expect.objectContaining({
363
- added: expect.objectContaining({
364
- [author.id]: author,
365
- }),
366
- }),
367
- })
368
- )
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
- removeListener()
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
- const book = Book.create({
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
- // Wait for async history flush
380
- await new Promise((resolve) => requestAnimationFrame(resolve))
219
+ expect(reactiveReads).toEqual(['J.R.R Tolkein', 'Jimmy Tolks'])
220
+ stop()
381
221
 
382
- // Should still be called only once
383
- expect(listener).toHaveBeenCalledTimes(1)
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
- it('filters listeners by source', async () => {
387
- const userListener = vi.fn()
388
- const remoteListener = vi.fn()
389
-
390
- store.listen(userListener, { source: 'user', scope: 'all' })
391
- store.listen(remoteListener, { source: 'remote', scope: 'all' })
233
+ describe('Store: serialization and snapshots (S)', () => {
234
+ let store: Store<LibraryType>
392
235
 
393
- const author = Author.create({ name: 'J.R.R. Tolkien' })
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
- // User change
396
- store.put([author])
397
- await new Promise((resolve) => requestAnimationFrame(resolve))
251
+ afterEach(() => {
252
+ store.dispose()
253
+ })
398
254
 
399
- expect(userListener).toHaveBeenCalledTimes(1)
400
- expect(remoteListener).not.toHaveBeenCalled()
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
- // Remote change
403
- store.mergeRemoteChanges(() => {
404
- const book = Book.create({
405
- title: 'The Hobbit',
406
- author: author.id,
407
- numPages: 310,
408
- })
409
- store.put([book])
410
- })
411
- await new Promise((resolve) => requestAnimationFrame(resolve))
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
- expect(userListener).toHaveBeenCalledTimes(1)
414
- expect(remoteListener).toHaveBeenCalledTimes(1)
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
- it('filters listeners by scope', async () => {
418
- const documentListener = vi.fn()
419
- const sessionListener = vi.fn()
420
- const presenceListener = vi.fn()
421
-
422
- store.listen(documentListener, { source: 'all', scope: 'document' })
423
- store.listen(sessionListener, { source: 'all', scope: 'session' })
424
- store.listen(presenceListener, { source: 'all', scope: 'presence' })
425
-
426
- const author = Author.create({ name: 'J.R.R. Tolkien' }) // document scope
427
- const visit = Visit.create({ visitorName: 'John Doe' }) // session scope
428
- const cursor = Cursor.create({ x: 100, y: 200, userId: 'user1' }) // presence scope
429
-
430
- store.put([author, visit, cursor])
431
- await new Promise((resolve) => requestAnimationFrame(resolve))
432
-
433
- expect(documentListener).toHaveBeenCalledTimes(1)
434
- expect(sessionListener).toHaveBeenCalledTimes(1)
435
- expect(presenceListener).toHaveBeenCalledTimes(1)
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
- // Check that each listener only received records from their scope
438
- expect(documentListener.mock.calls[0][0].changes.added).toHaveProperty(author.id)
439
- expect(documentListener.mock.calls[0][0].changes.added).not.toHaveProperty(visit.id)
440
- expect(documentListener.mock.calls[0][0].changes.added).not.toHaveProperty(cursor.id)
284
+ it('[S9] loadStoreSnapshot replaces all current data', () => {
285
+ const snapshot = store.getStoreSnapshot()
441
286
 
442
- expect(sessionListener.mock.calls[0][0].changes.added).not.toHaveProperty(author.id)
443
- expect(sessionListener.mock.calls[0][0].changes.added).toHaveProperty(visit.id)
444
- expect(sessionListener.mock.calls[0][0].changes.added).not.toHaveProperty(cursor.id)
287
+ const store2 = new Store({ props: {}, schema: schema() })
288
+ store2.put([Author.create({ name: 'Someone Else' })])
289
+ store2.loadStoreSnapshot(snapshot)
445
290
 
446
- expect(presenceListener.mock.calls[0][0].changes.added).not.toHaveProperty(author.id)
447
- expect(presenceListener.mock.calls[0][0].changes.added).not.toHaveProperty(visit.id)
448
- expect(presenceListener.mock.calls[0][0].changes.added).toHaveProperty(cursor.id)
449
- })
291
+ expect(store2.serialize('all')).toEqual(store.serialize('document'))
292
+ expect(store2.getStoreSnapshot()).toEqual(snapshot)
293
+ store2.dispose()
294
+ })
450
295
 
451
- it('flushes history before adding listeners', async () => {
452
- const author = Author.create({ name: 'J.R.R. Tolkien' })
453
- store.put([author])
296
+ it('[S9] loadStoreSnapshot does not run side effects', () => {
297
+ const snapshot = store.getStoreSnapshot()
454
298
 
455
- // Add listener after changes
456
- const listener = vi.fn()
457
- store.listen(listener)
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
- // Should not receive historical changes
460
- await new Promise((resolve) => requestAnimationFrame(resolve))
461
- expect(listener).not.toHaveBeenCalled()
305
+ store2.loadStoreSnapshot(snapshot)
462
306
 
463
- // Should receive new changes
464
- const book = Book.create({
465
- title: 'The Hobbit',
466
- author: author.id,
467
- numPages: 310,
468
- })
469
- store.put([book])
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
- await new Promise((resolve) => requestAnimationFrame(resolve))
472
- expect(listener).toHaveBeenCalledTimes(1)
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
- describe('remote changes', () => {
477
- it('merges remote changes with correct source', async () => {
478
- const listener = vi.fn()
479
- store.listen(listener)
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
- const author = Author.create({ name: 'J.R.R. Tolkien' })
337
+ store2.loadStoreSnapshot(snapshot)
482
338
 
483
- store.mergeRemoteChanges(() => {
484
- store.put([author])
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
- await new Promise((resolve) => requestAnimationFrame(resolve))
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
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ source: 'remote' }))
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
- it('ensures store is usable after remote changes', () => {
493
- const author = Author.create({ name: 'J.R.R. Tolkien' })
368
+ store2.loadStoreSnapshot(snapshot)
494
369
 
495
- store.mergeRemoteChanges(() => {
496
- store.put([author])
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
- expect(store.get(author.id)).toEqual(author)
500
- })
376
+ it('[S9] loadStoreSnapshot throws on migration failure and leaves the store unchanged', () => {
377
+ const snapshot = store.getStoreSnapshot()
501
378
 
502
- it('throws error when merging remote changes during atomic operation', () => {
503
- expect(() => {
504
- store.atomic(() => {
505
- store.mergeRemoteChanges(() => {
506
- // This should throw
507
- })
508
- })
509
- }).toThrow('Cannot merge remote changes while in atomic operation')
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
- describe('serialization and snapshots', () => {
514
- beforeEach(() => {
515
- const author = Author.create({ name: 'J.R.R. Tolkien' })
516
- const book = Book.create({
517
- title: 'The Hobbit',
518
- author: author.id,
519
- numPages: 310,
520
- })
521
- const visit = Visit.create({ visitorName: 'John Doe' })
522
- const cursor = Cursor.create({ x: 100, y: 200, userId: 'user1' })
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
- store.put([author, book, visit, cursor])
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
- it('serializes store with document scope by default', () => {
528
- const serialized = store.serialize()
529
- const records = Object.values(serialized)
530
-
531
- // Should include document records only
532
- expect(records.some((r) => r.typeName === 'author')).toBe(true)
533
- expect(records.some((r) => r.typeName === 'book')).toBe(true)
534
- expect(records.some((r) => r.typeName === 'visit')).toBe(false)
535
- expect(records.some((r) => r.typeName === 'cursor')).toBe(false)
536
- })
420
+ expect(() =>
421
+ store.migrateSnapshot({ store: {}, schema: { schemaVersion: -1 } } as any)
422
+ ).toThrow('Failed to migrate snapshot')
423
+ })
424
+ })
537
425
 
538
- it('serializes store with specific scope', () => {
539
- const sessionSerialized = store.serialize('session')
540
- const sessionRecords = Object.values(sessionSerialized)
426
+ describe('Store: validation (V)', () => {
427
+ let store: Store<LibraryType>
541
428
 
542
- expect(sessionRecords.some((r) => r.typeName === 'visit')).toBe(true)
543
- expect(sessionRecords.some((r) => r.typeName === 'author')).toBe(false)
544
- })
429
+ beforeEach(() => {
430
+ store = new Store({ props: {}, schema: schema() })
431
+ })
545
432
 
546
- it('serializes store with all scopes', () => {
547
- const allSerialized = store.serialize('all')
548
- const allRecords = Object.values(allSerialized)
433
+ afterEach(() => {
434
+ store.dispose()
435
+ })
549
436
 
550
- expect(allRecords.some((r) => r.typeName === 'author')).toBe(true)
551
- expect(allRecords.some((r) => r.typeName === 'book')).toBe(true)
552
- expect(allRecords.some((r) => r.typeName === 'visit')).toBe(true)
553
- expect(allRecords.some((r) => r.typeName === 'cursor')).toBe(true)
554
- })
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
- it('creates and loads store snapshots', () => {
557
- const snapshot = store.getStoreSnapshot()
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
- expect(snapshot).toHaveProperty('store')
560
- expect(snapshot).toHaveProperty('schema')
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
- const newStore = new Store({
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: StoreSchema.create<LibraryType>({
565
- book: Book,
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
- newStore.loadStoreSnapshot(snapshot)
573
-
574
- // Should have same document records (default scope)
575
- const originalDocumentRecords = store.serialize('document')
576
- const newDocumentRecords = newStore.serialize('document')
577
-
578
- expect(newDocumentRecords).toEqual(originalDocumentRecords)
579
- newStore.dispose()
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
- it('migrates snapshots', () => {
583
- const snapshot = store.getStoreSnapshot()
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
- it('throws error on migration failure', () => {
590
- const invalidSnapshot = {
591
- store: {},
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
- expect(() => store.migrateSnapshot(invalidSnapshot)).toThrow()
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
- describe('computed caches', () => {
600
- it('creates and uses computed cache', () => {
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
- const cache = store.createComputedCache('expensiveBook', computeExpensiveData)
604
-
605
- const book = Book.create({
606
- title: 'The Hobbit',
607
- author: Author.createId('tolkien'),
608
- numPages: 310,
609
- })
610
- store.put([book])
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
- const result1 = cache.get(book.id)
613
- expect(result1).toBe('expensive-The Hobbit')
614
- expect(computeExpensiveData).toHaveBeenCalledTimes(1)
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
- // Should use cached result
617
- const result2 = cache.get(book.id)
618
- expect(result2).toBe('expensive-The Hobbit')
619
- expect(computeExpensiveData).toHaveBeenCalledTimes(1)
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
- it('invalidates cache when record changes', () => {
623
- const computeExpensiveData = vi.fn((book: Book) => `expensive-${book.title}`)
624
- const cache = store.createComputedCache('expensiveBook', computeExpensiveData)
625
-
626
- const book = Book.create({
627
- title: 'The Hobbit',
628
- author: Author.createId('tolkien'),
629
- numPages: 310,
630
- })
631
- store.put([book])
535
+ const id = FixableAuthor.createId('a')
632
536
 
633
- cache.get(book.id)
634
- expect(computeExpensiveData).toHaveBeenCalledTimes(1)
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
- // Update the book
637
- store.update(book.id, (b) => ({ ...b, title: 'The Hobbit: Updated' }))
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
- // Should recompute
640
- const result = cache.get(book.id)
641
- expect(result).toBe('expensive-The Hobbit: Updated')
642
- expect(computeExpensiveData).toHaveBeenCalledTimes(2)
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
- describe('standalone createComputedCache', () => {
647
- it('works with store objects', () => {
648
- const derive = vi.fn(
649
- (context: { store: Store<LibraryType> }, book: Book) => `${book.title}-${context.store.id}`
650
- )
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
- const cache = createComputedCache('standalone', derive)
590
+ describe('computed caches (CC)', () => {
591
+ let store: Store<LibraryType>
653
592
 
654
- const book = Book.create({
655
- title: 'The Hobbit',
656
- author: Author.createId('tolkien'),
657
- numPages: 310,
658
- })
659
- store.put([book])
593
+ beforeEach(() => {
594
+ store = new Store({ props: {}, schema: schema() })
595
+ })
660
596
 
661
- const result = cache.get({ store }, book.id)
662
- expect(result).toBe(`The Hobbit-${store.id}`)
663
- expect(derive).toHaveBeenCalledTimes(1)
664
- })
597
+ afterEach(() => {
598
+ store.dispose()
599
+ })
665
600
 
666
- it('works directly with store instances', () => {
667
- const derive = vi.fn((store: Store<LibraryType>, book: Book) => `${book.title}-${store.id}`)
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
- const cache = createComputedCache('standalone', derive)
605
+ expect(cache.get(Book.createId('missing'))).toBeUndefined()
606
+ expect(derive).not.toHaveBeenCalled()
670
607
 
671
- const book = Book.create({
672
- title: 'The Hobbit',
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
- const result = cache.get(store, book.id)
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
- describe('diff application', () => {
685
- it('applies diffs to the store', () => {
686
- const author = Author.create({ name: 'J.R.R. Tolkien' })
687
- const book = Book.create({
688
- title: 'The Hobbit',
689
- author: author.id,
690
- numPages: 310,
691
- })
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
- const diff: RecordsDiff<LibraryType> = {
694
- added: {
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
- store.applyDiff(diff)
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
- expect(store.get(author.id)).toEqual(author)
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
- it('applies diffs with updates', () => {
709
- const author = Author.create({ name: 'J.R.R. Tolkien' })
710
- store.put([author])
627
+ expect(cache.get(book.id)).toBe('derived-The Hobbit: Updated')
628
+ expect(derive).toHaveBeenCalledTimes(2)
629
+ })
711
630
 
712
- const updatedAuthor = { ...author, name: 'John Ronald Reuel Tolkien' }
713
- const diff: RecordsDiff<LibraryType> = {
714
- added: {},
715
- updated: {
716
- [author.id]: [author, updatedAuthor],
717
- },
718
- removed: {},
719
- }
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
- store.applyDiff(diff)
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
- expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien')
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
- it('applies diffs with removals', () => {
727
- const author = Author.create({ name: 'J.R.R. Tolkien' })
728
- store.put([author])
729
-
730
- const diff: RecordsDiff<LibraryType> = {
731
- added: {},
732
- updated: {},
733
- removed: {
734
- [author.id]: author,
735
- },
736
- }
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
- store.applyDiff(diff)
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
- expect(store.has(author.id)).toBe(false)
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
- describe('validation', () => {
745
- it('validates records during operations', () => {
746
- expect(() => {
747
- store.put([
748
- {
749
- id: Book.createId('test1'),
750
- typeName: 'book',
751
- title: '', // Invalid: empty title
752
- author: Author.createId('tolkien'),
753
- numPages: 100,
754
- inStock: true,
755
- } as any,
756
- ])
757
- }).toThrow('Book must have non-empty title string')
758
- })
759
- })
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
- describe('side effects integration', () => {
762
- it('integrates with side effects for put operations', () => {
763
- const beforeCreate = vi.fn((record: Author) => record)
764
- const afterCreate = vi.fn()
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
- store.sideEffects.registerBeforeCreateHandler('author', beforeCreate)
767
- store.sideEffects.registerAfterCreateHandler('author', afterCreate)
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
- const author = Author.create({ name: 'J.R.R. Tolkien' })
770
- store.put([author])
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
- expect(beforeCreate).toHaveBeenCalledWith(author, 'user')
773
- expect(afterCreate).toHaveBeenCalledWith(author, 'user')
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
- it('integrates with side effects for update operations', () => {
777
- const beforeChange = vi.fn((prev: Author, next: Author) => next)
778
- const afterChange = vi.fn()
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
- const author = Author.create({ name: 'J.R.R. Tolkien' })
781
- store.put([author])
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
- store.sideEffects.registerBeforeChangeHandler('author', beforeChange)
784
- store.sideEffects.registerAfterChangeHandler('author', afterChange)
700
+ const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
701
+ store.put([book])
785
702
 
786
- const updatedAuthor = { ...author, name: 'John Ronald Reuel Tolkien' }
787
- store.put([updatedAuthor])
703
+ expect(cache.get({ store }, book.id)).toBe(`The Hobbit-${store.id}`)
704
+ })
788
705
 
789
- expect(beforeChange).toHaveBeenCalledWith(author, updatedAuthor, 'user')
790
- expect(afterChange).toHaveBeenCalledWith(author, updatedAuthor, 'user')
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
- it('integrates with side effects for remove operations', () => {
794
- const beforeDelete = vi.fn()
795
- const afterDelete = vi.fn()
796
-
797
- const author = Author.create({ name: 'J.R.R. Tolkien' })
798
- store.put([author])
713
+ expect(cache.get(Book.createId('missing'))).toBeUndefined()
714
+ expect(create).not.toHaveBeenCalled()
799
715
 
800
- store.sideEffects.registerBeforeDeleteHandler('author', beforeDelete)
801
- store.sideEffects.registerAfterDeleteHandler('author', afterDelete)
716
+ const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
717
+ store.put([book])
802
718
 
803
- store.remove([author.id])
804
-
805
- expect(beforeDelete).toHaveBeenCalledWith(author, 'user')
806
- expect(afterDelete).toHaveBeenCalledWith(author, 'user')
807
- })
719
+ expect(cache.get(book.id)).toEqual(book)
720
+ cache.get(book.id)
721
+ expect(create).toHaveBeenCalledTimes(1)
722
+ })
723
+ })
808
724
 
809
- it('can prevent deletion with beforeDelete handler', () => {
810
- const beforeDelete = vi.fn().mockReturnValue(false)
811
- const afterDelete = vi.fn()
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
- store.sideEffects.registerBeforeDeleteHandler('author', beforeDelete)
814
- store.sideEffects.registerAfterDeleteHandler('author', afterDelete)
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
- const author = Author.create({ name: 'Protected Author' })
817
- store.put([author])
752
+ store.mergeRemoteChanges(() => {
753
+ store.put([Author.create({ name: 'J.R.R Tolkein' })])
754
+ })
818
755
 
819
- // Try to delete - should be prevented
820
- store.remove([author.id])
756
+ expect(integrityChecker).toHaveBeenCalled()
757
+ store.dispose()
758
+ })
821
759
 
822
- expect(beforeDelete).toHaveBeenCalledWith(author, 'user')
823
- expect(afterDelete).not.toHaveBeenCalled()
824
- expect(store.has(author.id)).toBe(true) // Should still exist
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
  })