@tldraw/store 5.2.0-next.e2b8d10bf10e → 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,226 +1,1064 @@
1
- import { describe, expect, it, vi } from 'vitest'
1
+ /* eslint-disable @typescript-eslint/no-deprecated -- serializeEarliestVersion (SC3) is part of the tested contract */
2
+ import { assert } from '@tldraw/utils'
3
+ import { describe, expect, it, test, vi } from 'vitest'
2
4
  import { BaseRecord, RecordId } from './BaseRecord'
3
- import { createMigrationSequence } from './migrate'
5
+ import {
6
+ createMigrationSequence,
7
+ Migration,
8
+ MigrationSequence,
9
+ SynchronousStorage,
10
+ } from './migrate'
4
11
  import { createRecordType } from './RecordType'
5
- import { Store } from './Store'
6
- import { StoreSchema } from './StoreSchema'
12
+ import { SerializedStore, Store } from './Store'
13
+ import {
14
+ SerializedSchema,
15
+ SerializedSchemaV1,
16
+ SerializedSchemaV2,
17
+ StoreSchema,
18
+ upgradeSchema,
19
+ } from './StoreSchema'
20
+ import { testSchemaV0 } from './test/testSchema.v0'
21
+ import { testSchemaV1 } from './test/testSchema.v1'
22
+
23
+ // Tests for SPEC.md §15 (schema), §18 (migration selection), §19 (applying migrations to
24
+ // records), and §20 (applying migrations to snapshots and storage). Rule IDs like [MG4] in
25
+ // test names refer to that document.
7
26
 
8
- // Test record types
9
27
  interface Book extends BaseRecord<'book', RecordId<Book>> {
10
28
  title: string
11
- author: string
12
- publishedYear: number
13
- genre?: string
14
- inStock?: boolean
15
29
  }
16
30
 
17
31
  const Book = createRecordType<Book>('book', {
18
32
  validator: { validate: (book) => book as Book },
19
33
  scope: 'document',
20
- }).withDefaultProperties(() => ({
21
- inStock: true,
22
- publishedYear: 2023,
23
- }))
24
-
25
- interface Author extends BaseRecord<'author', RecordId<Author>> {
26
- name: string
27
- birthYear: number
28
- isAlive?: boolean
29
- }
34
+ })
30
35
 
31
- const Author = createRecordType<Author>('author', {
32
- validator: { validate: (author) => author as Author },
33
- scope: 'document',
34
- }).withDefaultProperties(() => ({
35
- isAlive: true,
36
- }))
37
-
38
- describe('StoreSchema', () => {
39
- describe('create', () => {
40
- it('validates migration dependencies during creation', () => {
41
- const invalidMigrations = createMigrationSequence({
42
- sequenceId: 'com.tldraw.book',
43
- sequence: [
44
- {
45
- id: 'com.tldraw.book/1',
46
- scope: 'record',
47
- dependsOn: ['com.tldraw.book/999'],
48
- up: (record: any) => record,
49
- },
50
- ],
51
- })
36
+ const noopMigration = (id: `${string}/${number}`, dependsOn?: string[]): Migration => ({
37
+ id,
38
+ scope: 'record',
39
+ dependsOn: dependsOn as any,
40
+ up: (r) => r,
41
+ })
52
42
 
53
- expect(() => {
54
- StoreSchema.create({ book: Book }, { migrations: [invalidMigrations] })
55
- }).toThrow()
43
+ describe('schema creation (SC)', () => {
44
+ it('[SC1] throws for duplicate migration sequence ids', () => {
45
+ const migration1 = createMigrationSequence({
46
+ sequenceId: 'com.tldraw.book',
47
+ sequence: [noopMigration('com.tldraw.book/1')],
48
+ })
49
+ const migration2 = createMigrationSequence({
50
+ sequenceId: 'com.tldraw.book',
51
+ sequence: [noopMigration('com.tldraw.book/1')],
56
52
  })
57
53
 
58
- it('prevents duplicate migration sequence IDs', () => {
59
- const migration1 = createMigrationSequence({
60
- sequenceId: 'com.tldraw.book',
61
- sequence: [
62
- {
63
- id: 'com.tldraw.book/1',
64
- scope: 'record',
65
- up: (record: any) => record,
66
- },
67
- ],
68
- })
54
+ expect(() => {
55
+ StoreSchema.create({ book: Book }, { migrations: [migration1, migration2] })
56
+ }).toThrow('Duplicate migration sequenceId com.tldraw.book')
57
+ })
69
58
 
70
- const migration2 = createMigrationSequence({
71
- sequenceId: 'com.tldraw.book', // Same ID
72
- sequence: [
73
- {
74
- id: 'com.tldraw.book/1',
75
- scope: 'record',
76
- up: (record: any) => record,
77
- },
59
+ it('[SC1] throws for invalid migration sequences', () => {
60
+ expect(() => {
61
+ StoreSchema.create(
62
+ { book: Book },
63
+ {
64
+ migrations: [
65
+ {
66
+ sequenceId: 'com.tldraw.book',
67
+ retroactive: true,
68
+ sequence: [noopMigration('com.tldraw.book/2')],
69
+ },
70
+ ],
71
+ }
72
+ )
73
+ }).toThrow()
74
+ })
75
+
76
+ it('[SC1] throws when a dependsOn references a missing migration', () => {
77
+ expect(() => {
78
+ StoreSchema.create(
79
+ {},
80
+ {
81
+ migrations: [
82
+ {
83
+ sequenceId: 'foo',
84
+ retroactive: false,
85
+ sequence: [noopMigration('foo/1', ['bar/1'])],
86
+ },
87
+ ],
88
+ }
89
+ )
90
+ }).toThrowErrorMatchingInlineSnapshot(
91
+ `[Error: Migration 'foo/1' depends on missing migration 'bar/1']`
92
+ )
93
+ })
94
+
95
+ it('[MS1] [MS2] sortedMigrations is sorted regardless of registration order', () => {
96
+ const foo: MigrationSequence = {
97
+ sequenceId: 'foo',
98
+ retroactive: false,
99
+ sequence: [noopMigration('foo/1', ['bar/1'])],
100
+ }
101
+ const bar: MigrationSequence = {
102
+ sequenceId: 'bar',
103
+ retroactive: false,
104
+ sequence: [noopMigration('bar/1')],
105
+ }
106
+ const s = StoreSchema.create({}, { migrations: [foo, bar] })
107
+ const s2 = StoreSchema.create({}, { migrations: [bar, foo] })
108
+
109
+ expect(s.sortedMigrations.map((m) => m.id)).toEqual(['bar/1', 'foo/1'])
110
+ expect(s2.sortedMigrations).toEqual(s.sortedMigrations)
111
+ })
112
+
113
+ it('[SC1] a standalone dependsOn entry participates in dependency checking', () => {
114
+ expect(() => {
115
+ StoreSchema.create(
116
+ {},
117
+ {
118
+ migrations: [
119
+ createMigrationSequence({
120
+ sequenceId: 'foo',
121
+ retroactive: false,
122
+ sequence: [{ dependsOn: ['bar/1'] }, noopMigration('foo/1')],
123
+ }),
124
+ ],
125
+ }
126
+ )
127
+ }).toThrowErrorMatchingInlineSnapshot(
128
+ `[Error: Migration 'foo/1' depends on missing migration 'bar/1']`
129
+ )
130
+ })
131
+
132
+ it('[SC2] serialize maps each sequence to the version of its last migration', () => {
133
+ const schema = StoreSchema.create(
134
+ { book: Book },
135
+ {
136
+ migrations: [
137
+ createMigrationSequence({
138
+ sequenceId: 'com.tldraw.book',
139
+ sequence: [noopMigration('com.tldraw.book/1'), noopMigration('com.tldraw.book/2')],
140
+ }),
141
+ createMigrationSequence({
142
+ sequenceId: 'com.tldraw.author',
143
+ sequence: [noopMigration('com.tldraw.author/1')],
144
+ }),
78
145
  ],
79
- })
146
+ }
147
+ )
80
148
 
81
- expect(() => {
82
- StoreSchema.create({ book: Book }, { migrations: [migration1, migration2] })
83
- }).toThrow('Duplicate migration sequenceId com.tldraw.book')
149
+ expect(schema.serialize()).toEqual({
150
+ schemaVersion: 2,
151
+ sequences: {
152
+ 'com.tldraw.book': 2,
153
+ 'com.tldraw.author': 1,
154
+ },
84
155
  })
85
156
  })
86
157
 
87
- describe('validateRecord', () => {
88
- it('throws for invalid record without validation failure handler', () => {
89
- // Create a validator that actually throws
90
- const StrictBook = createRecordType<Book>('book', {
91
- validator: {
92
- validate: (book) => {
93
- if (!(book as any).title || !(book as any).author) {
94
- throw new Error('Missing required fields')
95
- }
96
- return book as Book
97
- },
158
+ it('[SC2] an empty sequence serializes as version 0; no sequences serialize as {}', () => {
159
+ const schema = StoreSchema.create(
160
+ { book: Book },
161
+ {
162
+ migrations: [createMigrationSequence({ sequenceId: 'com.tldraw.book', sequence: [] })],
163
+ }
164
+ )
165
+ expect(schema.serialize()).toEqual({
166
+ schemaVersion: 2,
167
+ sequences: { 'com.tldraw.book': 0 },
168
+ })
169
+
170
+ expect(testSchemaV0.serialize()).toEqual({ schemaVersion: 2, sequences: {} })
171
+ expect(testSchemaV1.serialize()).toEqual({
172
+ schemaVersion: 2,
173
+ sequences: {
174
+ 'com.tldraw.shape': 2,
175
+ 'com.tldraw.shape.oval': 1,
176
+ 'com.tldraw.shape.rectangle': 1,
177
+ 'com.tldraw.store': 1,
178
+ 'com.tldraw.user': 2,
179
+ },
180
+ })
181
+ })
182
+
183
+ it('[SC3] serializeEarliestVersion maps every sequence to version 0', () => {
184
+ expect(testSchemaV1.serializeEarliestVersion()).toEqual({
185
+ schemaVersion: 2,
186
+ sequences: {
187
+ 'com.tldraw.shape': 0,
188
+ 'com.tldraw.shape.oval': 0,
189
+ 'com.tldraw.shape.rectangle': 0,
190
+ 'com.tldraw.store': 0,
191
+ 'com.tldraw.user': 0,
192
+ },
193
+ })
194
+ })
195
+
196
+ it('[SC4] getType returns the record type and throws for unknown names', () => {
197
+ const schema = StoreSchema.create({ book: Book })
198
+ expect(schema.getType('book')).toBe(Book)
199
+ expect(() => schema.getType('nonexistent')).toThrow('record type does not exist')
200
+ })
201
+ })
202
+
203
+ describe('validateRecord (V)', () => {
204
+ const StrictBook = createRecordType<Book>('book', {
205
+ validator: {
206
+ validate: (book) => {
207
+ if (!(book as Book).title) {
208
+ throw new Error('Missing required fields')
209
+ }
210
+ return book as Book
211
+ },
212
+ },
213
+ scope: 'document',
214
+ })
215
+
216
+ it('[V1] a validation error propagates when there is no failure handler', () => {
217
+ const schema = StoreSchema.create({ book: StrictBook })
218
+ const store = new Store({ schema, props: {} })
219
+
220
+ const invalidBook = { id: StrictBook.createId(), typeName: 'book' as const } as any
221
+
222
+ expect(() => {
223
+ schema.validateRecord(store, invalidBook, 'createRecord', null)
224
+ }).toThrow('Missing required fields')
225
+ })
226
+
227
+ it('[V1] throws for an unknown record type', () => {
228
+ const schema = StoreSchema.create({ book: Book })
229
+ const store = new Store({ schema, props: {} })
230
+
231
+ expect(() => {
232
+ schema.validateRecord(
233
+ store,
234
+ { id: 'unknown:1', typeName: 'unknown' } as any,
235
+ 'createRecord',
236
+ null
237
+ )
238
+ }).toThrow('Missing definition for record type unknown')
239
+ })
240
+
241
+ it('[V4] the validation failure handler receives the context and its return value is used', () => {
242
+ const fixedBook = {
243
+ id: Book.createId(),
244
+ typeName: 'book' as const,
245
+ title: 'Fixed Title',
246
+ }
247
+ const onValidationFailure = vi.fn().mockReturnValue(fixedBook)
248
+
249
+ const schema = StoreSchema.create({ book: StrictBook }, { onValidationFailure })
250
+ const store = new Store({ schema, props: {} })
251
+
252
+ const invalidBook = { id: StrictBook.createId(), typeName: 'book' as const } as any
253
+ const result = schema.validateRecord(store, invalidBook, 'createRecord', null)
254
+
255
+ expect(onValidationFailure).toHaveBeenCalledWith({
256
+ store,
257
+ record: invalidBook,
258
+ phase: 'createRecord',
259
+ recordBefore: null,
260
+ error: expect.any(Error),
261
+ })
262
+ expect(result).toBe(fixedBook)
263
+ })
264
+ })
265
+
266
+ describe('upgradeSchema (SC)', () => {
267
+ it('[SC5] upgrades a v1 schema to v2', () => {
268
+ const v1: SerializedSchemaV1 = {
269
+ schemaVersion: 1,
270
+ storeVersion: 4,
271
+ recordVersions: {
272
+ asset: {
273
+ version: 1,
274
+ subTypeKey: 'type',
275
+ subTypeVersions: { image: 2, video: 2, bookmark: 0 },
98
276
  },
99
- scope: 'document',
100
- })
101
-
102
- const schema = StoreSchema.create({ book: StrictBook })
103
- const store = new Store({ schema, props: {} })
104
-
105
- // Missing required fields
106
- const invalidBook = {
107
- id: StrictBook.createId(),
108
- typeName: 'book' as const,
109
- // Missing title, author, publishedYear
110
- } as any
111
-
112
- expect(() => {
113
- schema.validateRecord(store, invalidBook, 'createRecord', null)
114
- }).toThrow('Missing required fields')
115
- })
116
-
117
- it('calls validation failure handler for invalid record', () => {
118
- const onValidationFailure = vi.fn().mockReturnValue({
119
- id: Book.createId(),
120
- typeName: 'book' as const,
121
- title: 'Fixed Title',
122
- author: 'Fixed Author',
123
- publishedYear: 2023,
124
- inStock: true,
125
- })
126
-
127
- // Create a validator that throws
128
- const StrictBook = createRecordType<Book>('book', {
129
- validator: {
130
- validate: (book) => {
131
- if (!(book as any).title || !(book as any).author) {
132
- throw new Error('Missing required fields')
133
- }
134
- return book as Book
135
- },
277
+ camera: { version: 1 },
278
+ shape: {
279
+ version: 3,
280
+ subTypeKey: 'type',
281
+ subTypeVersions: { group: 0, text: 1 },
136
282
  },
137
- scope: 'document',
138
- })
283
+ },
284
+ }
139
285
 
140
- const schema = StoreSchema.create({ book: StrictBook }, { onValidationFailure })
141
- const store = new Store({ schema, props: {} })
286
+ expect(upgradeSchema(v1)).toEqual({
287
+ ok: true,
288
+ value: {
289
+ schemaVersion: 2,
290
+ sequences: {
291
+ 'com.tldraw.store': 4,
292
+ 'com.tldraw.asset': 1,
293
+ 'com.tldraw.asset.image': 2,
294
+ 'com.tldraw.asset.video': 2,
295
+ 'com.tldraw.asset.bookmark': 0,
296
+ 'com.tldraw.camera': 1,
297
+ 'com.tldraw.shape': 3,
298
+ 'com.tldraw.shape.group': 0,
299
+ 'com.tldraw.shape.text': 1,
300
+ },
301
+ },
302
+ })
303
+ })
142
304
 
143
- const invalidBook = {
144
- id: StrictBook.createId(),
145
- typeName: 'book' as const,
146
- } as any
305
+ it('[SC5] passes v2 schemas through unchanged', () => {
306
+ const v2: SerializedSchemaV2 = { schemaVersion: 2, sequences: { foo: 1 } }
307
+ const result = upgradeSchema(v2)
308
+ assert(result.ok)
309
+ expect(result.value).toBe(v2)
310
+ })
147
311
 
148
- const result = schema.validateRecord(store, invalidBook, 'createRecord', null)
312
+ it('[SC5] rejects unknown schema versions', () => {
313
+ expect(upgradeSchema({ schemaVersion: 3 } as any).ok).toBe(false)
314
+ expect(upgradeSchema({ schemaVersion: 0 } as any).ok).toBe(false)
315
+ })
316
+ })
149
317
 
150
- expect(onValidationFailure).toHaveBeenCalledWith({
151
- store,
152
- record: invalidBook,
153
- phase: 'createRecord',
154
- recordBefore: null,
155
- error: expect.any(Error),
156
- })
157
- expect(result.title).toBe('Fixed Title')
318
+ const mockSequence = ({
319
+ id,
320
+ retroactive,
321
+ versions,
322
+ filter,
323
+ }: {
324
+ id: string
325
+ retroactive: boolean
326
+ versions: number
327
+ filter?(r: TestRecordType): boolean
328
+ }): MigrationSequence => ({
329
+ sequenceId: id,
330
+ retroactive,
331
+ sequence: new Array(versions).fill(0).map((_, i) => ({
332
+ id: `${id}/${i + 1}`,
333
+ scope: 'record',
334
+ filter: filter as any,
335
+ up(r) {
336
+ const record = r as TestRecordType
337
+ record.versions[id] ??= 0
338
+ record.versions[id]++
339
+ },
340
+ down(r) {
341
+ const record = r as TestRecordType
342
+ record.versions[id]--
343
+ },
344
+ })),
345
+ })
346
+
347
+ interface TestRecordType extends BaseRecord<'test', RecordId<TestRecordType>> {
348
+ versions: Record<string, number>
349
+ }
350
+ const TestRecordType = createRecordType<TestRecordType>('test', {
351
+ scope: 'document',
352
+ })
353
+
354
+ const makeSchema = (migrations: MigrationSequence[]) =>
355
+ StoreSchema.create({ test: TestRecordType }, { migrations })
356
+
357
+ const makePersistedSchema = (...args: Array<[migrations: MigrationSequence, version: number]>) =>
358
+ ({
359
+ schemaVersion: 2,
360
+ sequences: Object.fromEntries(args.map(([m, v]) => [m.sequenceId, v])),
361
+ }) satisfies SerializedSchemaV2
362
+
363
+ const makeTestRecord = (persistedSchema: SerializedSchemaV2) =>
364
+ TestRecordType.create({
365
+ versions: Object.fromEntries(
366
+ Object.keys(persistedSchema.sequences).map((id) => [id, persistedSchema.sequences[id]])
367
+ ),
368
+ })
369
+
370
+ describe('getMigrationsSince (MG)', () => {
371
+ function getMigrationsBetween(
372
+ serialized: SerializedSchemaV2['sequences'],
373
+ current: MigrationSequence[]
374
+ ) {
375
+ const schema = StoreSchema.create({}, { migrations: current })
376
+ const ms = schema.getMigrationsSince({ schemaVersion: 2, sequences: serialized })
377
+ if (!ms.ok) {
378
+ throw new Error('Expected migrations to be found')
379
+ }
380
+ return ms.value.map((m) => m.id)
381
+ }
382
+
383
+ it('[MG1] returns the migrations after the persisted version, in order', () => {
384
+ const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
385
+ const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
386
+ const ids = getMigrationsBetween({ foo: 1, bar: 1 }, [foo, bar])
387
+
388
+ expect(ids.filter((id) => id.startsWith('foo'))).toEqual(['foo/2'])
389
+ expect(ids.filter((id) => id.startsWith('bar'))).toEqual(['bar/2', 'bar/3'])
390
+ })
391
+
392
+ it('[MG1] a persisted version of 0 selects the whole sequence', () => {
393
+ const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
394
+ const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
395
+
396
+ const ids = getMigrationsBetween({ foo: 0, bar: 0 }, [foo, bar])
397
+ expect(ids.filter((id) => id.startsWith('foo'))).toEqual(['foo/1', 'foo/2'])
398
+ expect(ids.filter((id) => id.startsWith('bar'))).toEqual(['bar/1', 'bar/2', 'bar/3'])
399
+ })
400
+
401
+ it('[MG2] sequences at the current version contribute nothing', () => {
402
+ const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
403
+ const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
404
+ expect(getMigrationsBetween({ foo: 2, bar: 3 }, [foo, bar])).toEqual([])
405
+
406
+ const emptyFoo = mockSequence({ id: 'foo', retroactive: true, versions: 0 })
407
+ const emptyBar = mockSequence({ id: 'bar', retroactive: false, versions: 0 })
408
+ expect(getMigrationsBetween({ foo: 0, bar: 0 }, [emptyFoo, emptyBar])).toEqual([])
409
+ })
410
+
411
+ it('[MG3] sequences unknown to the current schema are ignored', () => {
412
+ const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
413
+ expect(getMigrationsBetween({ foo: 2, someoneElses: 1 }, [foo])).toEqual([])
414
+ })
415
+
416
+ it('[MG4] a sequence missing from the persisted schema is included in full iff retroactive', () => {
417
+ const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
418
+ const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
419
+
420
+ const ids = getMigrationsBetween({}, [foo, bar])
421
+ expect(ids.filter((id) => id.startsWith('foo'))).toEqual(['foo/1', 'foo/2'])
422
+ expect(ids.filter((id) => id.startsWith('bar'))).toEqual([])
423
+
424
+ const allRetroactive = getMigrationsBetween({}, [
425
+ mockSequence({ id: 'foo', retroactive: true, versions: 2 }),
426
+ mockSequence({ id: 'bar', retroactive: true, versions: 3 }),
427
+ ])
428
+ expect(allRetroactive.filter((id) => id.startsWith('bar'))).toEqual(['bar/1', 'bar/2', 'bar/3'])
429
+
430
+ expect(
431
+ getMigrationsBetween({}, [
432
+ mockSequence({ id: 'foo', retroactive: false, versions: 2 }),
433
+ mockSequence({ id: 'bar', retroactive: false, versions: 3 }),
434
+ ])
435
+ ).toEqual([])
436
+ })
437
+
438
+ it('[MG5] a persisted version missing from the sequence is an error', () => {
439
+ const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
440
+ const schema = StoreSchema.create({}, { migrations: [foo] })
441
+
442
+ const result = schema.getMigrationsSince({ schemaVersion: 2, sequences: { foo: 999 } })
443
+ assert(!result.ok)
444
+ expect(result.error).toBe('Incompatible schema?')
445
+ })
446
+
447
+ it('[MG6] results are cached per persisted-schema object identity', () => {
448
+ const schema = makeSchema([mockSequence({ id: 'foo', retroactive: true, versions: 2 })])
449
+ const oldSchema = schema.serializeEarliestVersion()
450
+
451
+ const migrations1 = schema.getMigrationsSince(oldSchema)
452
+ assert(migrations1.ok)
453
+ expect(migrations1.value).toHaveLength(2)
454
+
455
+ const migrations2 = schema.getMigrationsSince(oldSchema)
456
+ assert(migrations2.ok)
457
+ expect(migrations2.value).toBe(migrations1.value)
458
+
459
+ // a structurally identical but distinct object misses the cache
460
+ const equivalent = structuredClone(oldSchema) as SerializedSchema
461
+ const migrations3 = schema.getMigrationsSince(equivalent)
462
+ assert(migrations3.ok)
463
+ expect(migrations3.value).not.toBe(migrations1.value)
464
+ expect(migrations3.value).toEqual(migrations1.value)
465
+ })
466
+
467
+ it('[MG6] empty and error results are cached too', () => {
468
+ const schema = makeSchema([])
469
+ const oldSchema = schema.serializeEarliestVersion()
470
+
471
+ const empty1 = schema.getMigrationsSince(oldSchema)
472
+ const empty2 = schema.getMigrationsSince(oldSchema)
473
+ assert(empty1.ok && empty2.ok)
474
+ expect(empty2.value).toBe(empty1.value)
475
+ expect(empty1.value).toHaveLength(0)
476
+
477
+ const incompatible: SerializedSchema = {
478
+ schemaVersion: 1,
479
+ storeVersion: 1,
480
+ recordVersions: { test: { version: 999 } },
481
+ }
482
+ const schemaWithMigrations = makeSchema([
483
+ {
484
+ sequenceId: 'com.tldraw.test',
485
+ retroactive: true,
486
+ sequence: [noopMigration('com.tldraw.test/1')],
487
+ },
488
+ ])
489
+ const err1 = schemaWithMigrations.getMigrationsSince(incompatible)
490
+ const err2 = schemaWithMigrations.getMigrationsSince(incompatible)
491
+ expect(err1.ok).toBe(false)
492
+ expect(err2).toBe(err1)
493
+ })
494
+
495
+ it('[MG7] v1 persisted schemas are upgraded before comparison', () => {
496
+ const userSequence = mockSequence({ id: 'com.tldraw.user', retroactive: false, versions: 2 })
497
+ const schema = StoreSchema.create({}, { migrations: [userSequence] })
498
+
499
+ const v1Schema: SerializedSchema = {
500
+ schemaVersion: 1,
501
+ storeVersion: 1,
502
+ recordVersions: { user: { version: 1 } },
503
+ }
504
+
505
+ const result = schema.getMigrationsSince(v1Schema)
506
+ assert(result.ok)
507
+ expect(result.value.map((m) => m.id)).toEqual(['com.tldraw.user/2'])
508
+ })
509
+ })
510
+
511
+ describe('migratePersistedRecord (MP)', () => {
512
+ it('[MP1] applies the needed migrations without mutating the input', () => {
513
+ const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
514
+ const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
515
+ const schema = makeSchema([foo, bar])
516
+ const persistedSchema = makePersistedSchema([foo, 0], [bar, 0])
517
+
518
+ const r = makeTestRecord(persistedSchema)
519
+ expect(r.versions).toEqual({ foo: 0, bar: 0 })
520
+ const update = schema.migratePersistedRecord(r, persistedSchema)
521
+ assert(update.type === 'success', 'the update should be successful')
522
+
523
+ // the original record did not change
524
+ expect(r.versions).toEqual({ foo: 0, bar: 0 })
525
+
526
+ // the updated record has the new versions
527
+ expect((update.value as TestRecordType).versions).toEqual({ foo: 2, bar: 3 })
528
+ })
529
+
530
+ it('[MP1] retroactivity applies per MG4 when the persisted schema lacks the sequence', () => {
531
+ const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
532
+ const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
533
+ const schema = makeSchema([foo, bar])
534
+ const persistedSchema = makePersistedSchema()
535
+
536
+ const r = makeTestRecord(persistedSchema)
537
+ const update = schema.migratePersistedRecord(r, persistedSchema)
538
+ assert(update.type === 'success', 'the update should be successful')
539
+ expect((update.value as TestRecordType).versions).toEqual({ foo: 2 })
540
+ })
541
+
542
+ it('[MP2] with no migrations to apply, the input record is returned as the value', () => {
543
+ const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
544
+ const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
545
+ const schema = makeSchema([foo, bar])
546
+ const persistedSchema = makePersistedSchema([foo, 2], [bar, 3])
547
+
548
+ const r = makeTestRecord(persistedSchema)
549
+
550
+ const up = schema.migratePersistedRecord(r, persistedSchema)
551
+ assert(up.type === 'success')
552
+ expect(up.value).toBe(r)
553
+
554
+ const down = schema.migratePersistedRecord(r, persistedSchema, 'down')
555
+ assert(down.type === 'success')
556
+ expect(down.value).toBe(r)
557
+ })
558
+
559
+ it('[MP3] a migrator may mutate in place or return a new record', () => {
560
+ const foo = mockSequence({ id: 'foo', retroactive: false, versions: 3 })
561
+ foo.sequence[1] = {
562
+ id: 'foo/2',
563
+ scope: 'record',
564
+ up(r) {
565
+ const record = r as TestRecordType
566
+ return { ...record, versions: { ...record.versions, foo: 2 } }
567
+ },
568
+ down(r) {
569
+ const record = r as TestRecordType
570
+ return { ...record, versions: { ...record.versions, foo: 1 } }
571
+ },
572
+ }
573
+ const schema = makeSchema([foo])
574
+ const v0Schema = makePersistedSchema([foo, 0])
575
+
576
+ const r0 = makeTestRecord(v0Schema)
577
+ const r3 = makeTestRecord(schema.serialize())
578
+ const update = schema.migratePersistedRecord(r0, v0Schema, 'up')
579
+ assert(update.type === 'success', 'the update should be successful')
580
+ expect((update.value as TestRecordType).versions).toEqual({ foo: 3 })
581
+ const update2 = schema.migratePersistedRecord(r3, v0Schema, 'down')
582
+ assert(update2.type === 'success', 'the update should be successful')
583
+ expect((update2.value as TestRecordType).versions).toEqual({ foo: 0 })
584
+ })
585
+
586
+ it('[MP4] a migration filter skips non-matching records', () => {
587
+ const foo = mockSequence({
588
+ id: 'foo',
589
+ retroactive: false,
590
+ versions: 2,
591
+ filter: (r) => (r as any).foo === true,
592
+ })
593
+ const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
594
+ const schema = makeSchema([foo, bar])
595
+ const persistedSchema = makePersistedSchema([foo, 0], [bar, 0])
596
+
597
+ const r = makeTestRecord(persistedSchema)
598
+ const update = schema.migratePersistedRecord(r, persistedSchema, 'up')
599
+ assert(update.type === 'success', 'the update should be successful')
600
+
601
+ // foo migrations were not applied
602
+ expect((update.value as TestRecordType).versions).toEqual({ foo: 0, bar: 3 })
603
+
604
+ const r2 = { ...r, foo: true }
605
+ const update2 = schema.migratePersistedRecord(r2, persistedSchema, 'up')
606
+ assert(update2.type === 'success', 'the update should be successful')
607
+
608
+ // foo migrations were applied
609
+ expect((update2.value as TestRecordType).versions).toEqual({ foo: 2, bar: 3 })
610
+ })
611
+
612
+ it('[MP5] errors when a store-scope migration is in the path', () => {
613
+ const foo = mockSequence({ id: 'foo', retroactive: false, versions: 3 })
614
+ foo.sequence[1] = {
615
+ id: 'foo/2',
616
+ scope: 'store',
617
+ up() {
618
+ // noop
619
+ },
620
+ down() {
621
+ // noop
622
+ },
623
+ }
624
+ const schema = makeSchema([foo])
625
+ const v0Schema = makePersistedSchema([foo, 0])
626
+
627
+ const r0 = makeTestRecord(v0Schema)
628
+ const r3 = makeTestRecord(schema.serialize())
629
+ expect(schema.migratePersistedRecord(r0, v0Schema, 'up')).toMatchObject({
630
+ type: 'error',
631
+ reason: 'target-version-too-new',
632
+ })
633
+ expect(schema.migratePersistedRecord(r3, v0Schema, 'down')).toMatchObject({
634
+ type: 'error',
635
+ reason: 'target-version-too-old',
158
636
  })
159
637
 
160
- it('throws for unknown record type', () => {
161
- const schema = StoreSchema.create({ book: Book })
162
- const store = new Store({ schema, props: {} })
638
+ // migrating a whole snapshot still works
639
+ const update3 = schema.migrateStoreSnapshot({
640
+ schema: v0Schema,
641
+ store: { [r0.id]: r0 },
642
+ })
643
+ assert(update3.type === 'success', 'the update should be successful')
644
+ expect((update3.value[r0.id] as TestRecordType).versions).toEqual({ foo: 2 })
645
+ })
163
646
 
164
- const unknownRecord = {
165
- id: 'unknown:1',
166
- typeName: 'unknown',
167
- } as any
647
+ it('[MP6] going down requires every migration to have a down migrator', () => {
648
+ const foo = mockSequence({ id: 'foo', retroactive: false, versions: 3 })
649
+ delete (foo.sequence[1] as any).down
650
+ const schema = makeSchema([foo])
651
+ const v0Schema = makePersistedSchema([foo, 0])
168
652
 
169
- expect(() => {
170
- schema.validateRecord(store, unknownRecord, 'createRecord', null)
171
- }).toThrow('Missing definition for record type unknown')
653
+ // going up still works
654
+ const r0 = makeTestRecord(v0Schema)
655
+ expect(schema.migratePersistedRecord(r0, v0Schema, 'up').type).toBe('success')
656
+
657
+ // going down does not
658
+ const r3 = makeTestRecord(schema.serialize())
659
+ expect(schema.migratePersistedRecord(r3, v0Schema, 'down')).toMatchObject({
660
+ type: 'error',
661
+ reason: 'target-version-too-old',
172
662
  })
173
663
  })
174
664
 
175
- describe('createIntegrityChecker', () => {
176
- it('calls createIntegrityChecker option when provided', () => {
177
- const createIntegrityChecker = vi.fn()
178
- const schema = StoreSchema.create({ book: Book }, { createIntegrityChecker })
179
- const store = new Store({ schema, props: {} })
665
+ it('[MP6] going down applies down migrators in reverse and restores the old shape', () => {
666
+ const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
667
+ const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
668
+ const schema = makeSchema([foo, bar])
669
+ const persistedSchema = makePersistedSchema([foo, 0], [bar, 0])
670
+
671
+ const r = makeTestRecord(schema.serialize())
672
+ expect(r.versions).toEqual({ foo: 2, bar: 3 })
673
+ const downgrade = schema.migratePersistedRecord(r, persistedSchema, 'down')
674
+ assert(downgrade.type === 'success', 'the downgrade should be successful')
675
+
676
+ expect(r.versions).toEqual({ foo: 2, bar: 3 })
677
+ expect((downgrade.value as TestRecordType).versions).toEqual({ foo: 0, bar: 0 })
678
+ })
679
+
680
+ it('[MP6] going down ignores sequences the persisted schema does not know unless retroactive', () => {
681
+ const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
682
+ const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
683
+ const schema = makeSchema([foo, bar])
684
+ const persistedSchema = makePersistedSchema()
685
+
686
+ const r = makeTestRecord(schema.serialize())
687
+ expect(r.versions).toEqual({ foo: 2, bar: 3 })
688
+ const downgrade = schema.migratePersistedRecord(r, persistedSchema, 'down')
689
+ assert(downgrade.type === 'success', 'the downgrade should be successful')
690
+
691
+ // only the foo migrations were undone
692
+ expect((downgrade.value as TestRecordType).versions).toEqual({ foo: 0, bar: 3 })
693
+ })
694
+
695
+ it('[MP7] a throwing migrator produces a migration-error result', () => {
696
+ const foo: MigrationSequence = {
697
+ sequenceId: 'foo',
698
+ retroactive: true,
699
+ sequence: [
700
+ {
701
+ id: 'foo/1',
702
+ scope: 'record',
703
+ up() {
704
+ throw new Error('boom')
705
+ },
706
+ },
707
+ ],
708
+ }
709
+ const schema = makeSchema([foo])
710
+ const v0Schema = makePersistedSchema([foo, 0])
711
+
712
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
713
+ const r = makeTestRecord(v0Schema)
714
+ expect(schema.migratePersistedRecord(r, v0Schema, 'up')).toMatchObject({
715
+ type: 'error',
716
+ reason: 'migration-error',
717
+ })
718
+ consoleSpy.mockRestore()
719
+ })
720
+ })
721
+
722
+ describe('migrateStoreSnapshot (MA)', () => {
723
+ test('[MA1] migrates every record in a snapshot, including v1 schemas with subtypes', () => {
724
+ const serializedStore: SerializedStore<any> = {
725
+ 'user-1': {
726
+ id: 'user-1',
727
+ typeName: 'user',
728
+ name: 'name',
729
+ },
730
+ 'shape-1': {
731
+ id: 'shape-1',
732
+ typeName: 'shape',
733
+ x: 0,
734
+ y: 0,
735
+ type: 'rectangle',
736
+ props: {
737
+ width: 100,
738
+ height: 100,
739
+ },
740
+ },
741
+ 'org-1': {
742
+ id: 'org-1',
743
+ typeName: 'org',
744
+ name: 'tldraw',
745
+ },
746
+ }
747
+
748
+ const result = testSchemaV1.migrateStoreSnapshot({
749
+ store: serializedStore,
750
+ schema: testSchemaV0.serialize(),
751
+ })
180
752
 
181
- schema.createIntegrityChecker(store)
753
+ assert(result.type === 'success', 'Migration failed')
182
754
 
183
- expect(createIntegrityChecker).toHaveBeenCalledWith(store)
755
+ // users and shapes are migrated; the store-scope migration removed the org record
756
+ expect(result.value).toEqual({
757
+ 'shape-1': {
758
+ id: 'shape-1',
759
+ typeName: 'shape',
760
+ x: 0,
761
+ y: 0,
762
+ type: 'rectangle',
763
+ parentId: null,
764
+ rotation: 0,
765
+ props: {
766
+ width: 100,
767
+ height: 100,
768
+ opacity: 1,
769
+ },
770
+ },
771
+ 'user-1': {
772
+ id: 'user-1',
773
+ typeName: 'user',
774
+ name: 'name',
775
+ locale: 'en',
776
+ phoneNumber: null,
777
+ },
184
778
  })
185
779
  })
186
780
 
187
- describe('serialize', () => {
188
- it('serializes schema with current migration versions', () => {
189
- const bookMigrations = createMigrationSequence({
190
- sequenceId: 'com.tldraw.book',
781
+ it('[MA1] does not modify the input snapshot by default; mutateInputStore mutates in place', () => {
782
+ const migrations = [
783
+ createMigrationSequence({
784
+ sequenceId: 'com.tldraw.test',
191
785
  sequence: [
192
- { id: 'com.tldraw.book/1', scope: 'record', up: (r: any) => r },
193
- { id: 'com.tldraw.book/2', scope: 'record', up: (r: any) => r },
786
+ {
787
+ id: 'com.tldraw.test/1',
788
+ scope: 'record',
789
+ up: (record: any) => {
790
+ record.version = 2
791
+ },
792
+ },
194
793
  ],
195
- })
794
+ }),
795
+ ]
796
+ const schema = StoreSchema.create({ test: TestRecordType }, { migrations })
797
+ const oldSchema = schema.serializeEarliestVersion()
196
798
 
197
- const authorMigrations = createMigrationSequence({
198
- sequenceId: 'com.tldraw.author',
199
- sequence: [{ id: 'com.tldraw.author/1', scope: 'record', up: (r: any) => r }],
200
- })
799
+ const store1: any = {
800
+ test1: { id: 'test1', version: 1, typeName: 'test', versions: {} },
801
+ }
802
+ const result1 = schema.migrateStoreSnapshot({ store: store1, schema: oldSchema })
803
+ assert(result1.type === 'success')
804
+ expect((result1.value as any).test1.version).toBe(2)
805
+ expect(store1.test1.version).toBe(1) // input untouched
806
+ // ...though in dev builds migration deep-freezes the input's records
807
+ expect(Object.isFrozen(store1.test1)).toBe(true)
201
808
 
202
- const schema = StoreSchema.create(
203
- { book: Book, author: Author },
204
- { migrations: [bookMigrations, authorMigrations] }
205
- )
809
+ const store2: any = {
810
+ test1: { id: 'test1', version: 1, typeName: 'test', versions: {} },
811
+ }
812
+ const result2 = schema.migrateStoreSnapshot(
813
+ { store: store2, schema: oldSchema },
814
+ { mutateInputStore: true }
815
+ )
816
+ assert(result2.type === 'success')
817
+ expect(result2.value).toBe(store2)
818
+ expect(store2.test1.version).toBe(2)
819
+ })
206
820
 
207
- const serialized = schema.serialize()
821
+ it('[MA2] a snapshot needing no migrations is returned as-is', () => {
822
+ const schema = makeSchema([])
823
+ const store: SerializedStore<TestRecordType> = {
824
+ [TestRecordType.createId('a')]: makeTestRecord({ schemaVersion: 2, sequences: {} }),
825
+ } as any
208
826
 
209
- expect(serialized).toEqual({
210
- schemaVersion: 2,
211
- sequences: {
212
- 'com.tldraw.book': 2,
213
- 'com.tldraw.author': 1,
827
+ const result = schema.migrateStoreSnapshot({ store, schema: schema.serialize() })
828
+ assert(result.type === 'success')
829
+ expect(result.value).toBe(store)
830
+ })
831
+
832
+ it('[MA6] a throwing migrator produces a migration-error result', () => {
833
+ const foo: MigrationSequence = {
834
+ sequenceId: 'foo',
835
+ retroactive: true,
836
+ sequence: [
837
+ {
838
+ id: 'foo/1',
839
+ scope: 'record',
840
+ up() {
841
+ throw new Error('boom')
842
+ },
214
843
  },
215
- })
844
+ ],
845
+ }
846
+ const schema = makeSchema([foo])
847
+
848
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
849
+ const result = schema.migrateStoreSnapshot({
850
+ store: {
851
+ [TestRecordType.createId('a')]: makeTestRecord({ schemaVersion: 2, sequences: {} }),
852
+ } as any,
853
+ schema: makePersistedSchema([foo, 0]),
216
854
  })
855
+ expect(result).toMatchObject({ type: 'error', reason: 'migration-error' })
856
+ consoleSpy.mockRestore()
857
+ })
858
+
859
+ it('[MA6] an unknown record type encountered during migration is an error', () => {
860
+ const foo = mockSequence({ id: 'foo', retroactive: true, versions: 1 })
861
+ const schema = makeSchema([foo])
862
+
863
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
864
+ const result = schema.migrateStoreSnapshot({
865
+ store: { 'mystery:1': { id: 'mystery:1', typeName: 'mystery' } } as any,
866
+ schema: makePersistedSchema([foo, 0]),
867
+ })
868
+ expect(result).toMatchObject({ type: 'error', reason: 'migration-error' })
869
+ consoleSpy.mockRestore()
870
+ })
871
+
872
+ it('[MA5] non-document records are dropped when migrations apply, kept when none apply', () => {
873
+ interface SessionThing extends BaseRecord<'sessionThing', RecordId<SessionThing>> {
874
+ label: string
875
+ }
876
+ const SessionThing = createRecordType<SessionThing>('sessionThing', {
877
+ scope: 'session',
878
+ }).withDefaultProperties(() => ({ label: '' }))
879
+
880
+ const foo: MigrationSequence = {
881
+ sequenceId: 'foo',
882
+ retroactive: true,
883
+ sequence: [
884
+ { id: 'foo/1', scope: 'record', filter: (r) => r.typeName === 'test', up: (r) => r },
885
+ ],
886
+ }
887
+ const schema = StoreSchema.create(
888
+ { test: TestRecordType, sessionThing: SessionThing },
889
+ { migrations: [foo] }
890
+ )
891
+
892
+ const sessionRecord = SessionThing.create({ id: SessionThing.createId('s') })
893
+ const testRecord = makeTestRecord({ schemaVersion: 2, sequences: {} })
894
+ const store = {
895
+ [sessionRecord.id]: sessionRecord,
896
+ [testRecord.id]: testRecord,
897
+ } as SerializedStore<any>
898
+
899
+ // migrations apply -> session record dropped
900
+ const migrated = schema.migrateStoreSnapshot({
901
+ store,
902
+ schema: makePersistedSchema([foo, 0]),
903
+ })
904
+ assert(migrated.type === 'success')
905
+ expect(Object.keys(migrated.value)).toEqual([testRecord.id])
906
+
907
+ // no migrations apply -> snapshot unchanged
908
+ const untouched = schema.migrateStoreSnapshot({ store, schema: schema.serialize() })
909
+ assert(untouched.type === 'success')
910
+ expect(untouched.value).toBe(store)
911
+ })
912
+ })
913
+
914
+ describe('migrateStorage (MA)', () => {
915
+ function makeStorage<R extends BaseRecord<any, any>>(
916
+ records: [string, R][],
917
+ schema: SerializedSchema
918
+ ): SynchronousStorage<R> & {
919
+ map: Map<string, R>
920
+ setCalls: string[]
921
+ setSchemaCalls: SerializedSchema[]
922
+ } {
923
+ const map = new Map(records)
924
+ const setCalls: string[] = []
925
+ const setSchemaCalls: SerializedSchema[] = []
926
+ return {
927
+ map,
928
+ setCalls,
929
+ setSchemaCalls,
930
+ get: (id) => map.get(id),
931
+ set: (id, record) => {
932
+ setCalls.push(id)
933
+ map.set(id, record)
934
+ },
935
+ delete: (id) => {
936
+ map.delete(id)
937
+ },
938
+ keys: () => map.keys(),
939
+ values: () => map.values(),
940
+ entries: () => map.entries(),
941
+ getSchema: () => schema,
942
+ setSchema: (s) => {
943
+ setSchemaCalls.push(s)
944
+ },
945
+ }
946
+ }
947
+
948
+ it('[MA7] writes the current schema and updates only records that actually changed', () => {
949
+ const migrations = [
950
+ createMigrationSequence({
951
+ sequenceId: 'foo',
952
+ sequence: [
953
+ {
954
+ id: 'foo/1',
955
+ scope: 'record',
956
+ filter: (r) => (r as any).shouldChange === true,
957
+ up: (record: any) => {
958
+ record.changed = true
959
+ },
960
+ },
961
+ ],
962
+ }),
963
+ ]
964
+ const schema = StoreSchema.create({ test: TestRecordType }, { migrations })
965
+
966
+ const changing = { ...makeTestRecord({ schemaVersion: 2, sequences: {} }), shouldChange: true }
967
+ const staying = makeTestRecord({ schemaVersion: 2, sequences: {} })
968
+ const storage = makeStorage(
969
+ [
970
+ [changing.id, changing],
971
+ [staying.id, staying],
972
+ ],
973
+ schema.serializeEarliestVersion()
974
+ )
975
+
976
+ schema.migrateStorage(storage as any)
977
+
978
+ expect(storage.setSchemaCalls).toEqual([schema.serialize()])
979
+ expect(storage.setCalls).toEqual([changing.id])
980
+ expect((storage.map.get(changing.id) as any).changed).toBe(true)
981
+ // the unchanged record was not rewritten and the original was not mutated
982
+ expect((changing as any).changed).toBeUndefined()
217
983
  })
218
984
 
219
- describe('getType', () => {
220
- it('throws for invalid type name', () => {
221
- const schema = StoreSchema.create({ book: Book })
985
+ it('[MA7] does nothing when no migrations are needed', () => {
986
+ const schema = StoreSchema.create({ test: TestRecordType }, { migrations: [] })
987
+ const record = makeTestRecord({ schemaVersion: 2, sequences: {} })
988
+ const storage = makeStorage([[record.id, record]], schema.serialize())
222
989
 
223
- expect(() => schema.getType('nonexistent')).toThrow('record type does not exists')
990
+ schema.migrateStorage(storage as any)
991
+
992
+ expect(storage.setSchemaCalls).toEqual([])
993
+ expect(storage.setCalls).toEqual([])
994
+ })
995
+
996
+ it('[MA4] storage-scope migrations receive the storage and may read and write records', () => {
997
+ // exercised end-to-end via Store.loadStoreSnapshot in Store.test.ts; here we check the
998
+ // storage object passed to the migrator is the one we provided
999
+ let receivedStorage: any
1000
+ const migrations = [
1001
+ createMigrationSequence({
1002
+ sequenceId: 'foo',
1003
+ sequence: [
1004
+ {
1005
+ id: 'foo/1',
1006
+ scope: 'storage',
1007
+ up: (storage) => {
1008
+ receivedStorage = storage
1009
+ for (const [id, record] of storage.entries()) {
1010
+ storage.set(id, { ...(record as any), touched: true })
1011
+ }
1012
+ },
1013
+ },
1014
+ ],
1015
+ }),
1016
+ ]
1017
+ const schema = StoreSchema.create({ test: TestRecordType }, { migrations })
1018
+ const record = makeTestRecord({ schemaVersion: 2, sequences: {} })
1019
+ const storage = makeStorage([[record.id, record]], schema.serializeEarliestVersion())
1020
+
1021
+ schema.migrateStorage(storage as any)
1022
+
1023
+ expect(receivedStorage).toBe(storage)
1024
+ expect((storage.map.get(record.id) as any).touched).toBe(true)
1025
+ })
1026
+
1027
+ it('[MA3] store-scope migrations can add, change, and delete records', () => {
1028
+ const recordA = { ...makeTestRecord({ schemaVersion: 2, sequences: {} }), name: 'a' }
1029
+ const recordB = { ...makeTestRecord({ schemaVersion: 2, sequences: {} }), name: 'b' }
1030
+ const newRecord = { ...makeTestRecord({ schemaVersion: 2, sequences: {} }), name: 'new' }
1031
+
1032
+ const migrations = [
1033
+ createMigrationSequence({
1034
+ sequenceId: 'foo',
1035
+ sequence: [
1036
+ {
1037
+ id: 'foo/1',
1038
+ scope: 'store',
1039
+ up: (store: any) => {
1040
+ const next: any = {}
1041
+ for (const [id, record] of Object.entries(store)) {
1042
+ if ((record as any).name === 'a') continue // delete a
1043
+ next[id] = { ...(record as any), renamed: true } // change b
1044
+ }
1045
+ next[newRecord.id] = newRecord // add new
1046
+ return next
1047
+ },
1048
+ },
1049
+ ],
1050
+ }),
1051
+ ]
1052
+ const schema = StoreSchema.create({ test: TestRecordType }, { migrations })
1053
+
1054
+ const result = schema.migrateStoreSnapshot({
1055
+ store: { [recordA.id]: recordA, [recordB.id]: recordB } as any,
1056
+ schema: schema.serializeEarliestVersion(),
224
1057
  })
1058
+ assert(result.type === 'success')
1059
+
1060
+ expect(result.value[recordA.id as keyof typeof result.value]).toBeUndefined()
1061
+ expect((result.value[recordB.id as keyof typeof result.value] as any).renamed).toBe(true)
1062
+ expect(result.value[newRecord.id as keyof typeof result.value]).toMatchObject({ name: 'new' })
225
1063
  })
226
1064
  })