@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.
- package/DOCS.md +790 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ImmutableMap.js +0 -13
- package/dist-cjs/lib/ImmutableMap.js.map +2 -2
- package/dist-cjs/lib/RecordType.js +1 -1
- package/dist-cjs/lib/RecordType.js.map +2 -2
- package/dist-cjs/lib/RecordsDiff.js +26 -14
- package/dist-cjs/lib/RecordsDiff.js.map +2 -2
- package/dist-cjs/lib/Store.js +6 -5
- package/dist-cjs/lib/Store.js.map +2 -2
- package/dist-cjs/lib/StoreSchema.js +1 -1
- package/dist-cjs/lib/StoreSchema.js.map +2 -2
- package/dist-cjs/lib/executeQuery.js +1 -1
- package/dist-cjs/lib/executeQuery.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ImmutableMap.mjs +0 -13
- package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
- package/dist-esm/lib/RecordType.mjs +1 -1
- package/dist-esm/lib/RecordType.mjs.map +2 -2
- package/dist-esm/lib/RecordsDiff.mjs +26 -14
- package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
- package/dist-esm/lib/Store.mjs +7 -6
- package/dist-esm/lib/Store.mjs.map +2 -2
- package/dist-esm/lib/StoreSchema.mjs +1 -1
- package/dist-esm/lib/StoreSchema.mjs.map +2 -2
- package/dist-esm/lib/executeQuery.mjs +1 -1
- package/dist-esm/lib/executeQuery.mjs.map +2 -2
- package/package.json +9 -5
- package/src/lib/{test/AtomMap.test.ts → AtomMap.test.ts} +77 -111
- package/src/lib/AtomSet.test.ts +116 -0
- package/src/lib/BaseRecord.test.ts +10 -22
- package/src/lib/ImmutableMap.test.ts +114 -71
- package/src/lib/ImmutableMap.ts +1 -0
- package/src/lib/IncrementalSetConstructor.test.ts +76 -81
- package/src/lib/RecordType.test.ts +216 -0
- package/src/lib/RecordType.ts +1 -1
- package/src/lib/RecordsDiff.test.ts +112 -106
- package/src/lib/RecordsDiff.ts +43 -18
- package/src/lib/Store.test.ts +570 -630
- package/src/lib/Store.ts +9 -10
- package/src/lib/StoreListeners.test.ts +462 -0
- package/src/lib/StoreQueries.test.ts +586 -434
- package/src/lib/StoreSchema.test.ts +1012 -174
- package/src/lib/StoreSchema.ts +1 -1
- package/src/lib/StoreSideEffects.test.ts +546 -158
- package/src/lib/devFreeze.test.ts +94 -124
- package/src/lib/executeQuery.test.ts +77 -31
- package/src/lib/executeQuery.ts +3 -1
- package/src/lib/migrate.test.ts +273 -296
- package/src/lib/setUtils.test.ts +38 -79
- package/src/lib/test/createMigrations.test.ts +0 -75
- package/src/lib/test/dependsOn.test.ts +0 -166
- package/src/lib/test/getMigrationsSince.test.ts +0 -121
- package/src/lib/test/migrate.test.ts +0 -118
- package/src/lib/test/migratePersistedRecord.test.ts +0 -265
- package/src/lib/test/migrationCaching.test.ts +0 -209
- package/src/lib/test/recordStore.test.ts +0 -1567
- package/src/lib/test/recordStoreQueries.test.ts +0 -814
- package/src/lib/test/recordType.test.ts +0 -19
- package/src/lib/test/sortMigrations.test.ts +0 -83
- package/src/lib/test/upgradeSchema.test.ts +0 -80
- package/src/lib/test/validate.test.ts +0 -178
- package/src/lib/test/validateMigrations.test.ts +0 -165
|
@@ -1,226 +1,1064 @@
|
|
|
1
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
})
|
|
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
|
|
32
|
-
|
|
33
|
-
scope: '
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
138
|
-
|
|
283
|
+
},
|
|
284
|
+
}
|
|
139
285
|
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
753
|
+
assert(result.type === 'success', 'Migration failed')
|
|
182
754
|
|
|
183
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
sequenceId: 'com.tldraw.
|
|
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
|
-
{
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
})
|