@tldraw/store 3.16.0-internal.a478398270c6 → 3.16.0-internal.f8b97f0c414f
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/README.md +13 -13
- package/dist-cjs/index.d.ts +10 -42
- package/dist-cjs/index.js +1 -2
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/RecordType.js +0 -16
- package/dist-cjs/lib/RecordType.js.map +2 -2
- package/dist-cjs/lib/RecordsDiff.js +3 -3
- package/dist-cjs/lib/RecordsDiff.js.map +2 -2
- package/dist-cjs/lib/Store.js +1 -20
- package/dist-cjs/lib/Store.js.map +2 -2
- package/dist-cjs/lib/StoreSchema.js +24 -8
- package/dist-cjs/lib/StoreSchema.js.map +3 -3
- package/dist-cjs/lib/migrate.js +57 -43
- package/dist-cjs/lib/migrate.js.map +2 -2
- package/dist-esm/index.d.mts +10 -42
- package/dist-esm/index.mjs +1 -3
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/RecordType.mjs +0 -16
- package/dist-esm/lib/RecordType.mjs.map +2 -2
- package/dist-esm/lib/RecordsDiff.mjs +3 -3
- package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
- package/dist-esm/lib/Store.mjs +1 -20
- package/dist-esm/lib/Store.mjs.map +2 -2
- package/dist-esm/lib/StoreSchema.mjs +24 -8
- package/dist-esm/lib/StoreSchema.mjs.map +3 -3
- package/dist-esm/lib/migrate.mjs +57 -43
- package/dist-esm/lib/migrate.mjs.map +2 -2
- package/package.json +12 -19
- package/src/index.ts +0 -1
- package/src/lib/RecordType.ts +0 -17
- package/src/lib/RecordsDiff.ts +9 -3
- package/src/lib/Store.ts +1 -22
- package/src/lib/StoreSchema.ts +33 -8
- package/src/lib/migrate.ts +106 -57
- package/src/lib/test/AtomMap.test.ts +2 -1
- package/src/lib/test/dependsOn.test.ts +2 -2
- package/src/lib/test/migrationCaching.test.ts +209 -0
- package/src/lib/test/recordStore.test.ts +40 -37
- package/src/lib/test/sortMigrations.test.ts +36 -4
- package/src/lib/test/validateMigrations.test.ts +8 -8
- package/src/lib/test/defineMigrations.test.ts +0 -232
package/src/lib/StoreSchema.ts
CHANGED
|
@@ -107,6 +107,7 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
|
|
|
107
107
|
|
|
108
108
|
readonly migrations: Record<string, MigrationSequence> = {}
|
|
109
109
|
readonly sortedMigrations: readonly Migration[]
|
|
110
|
+
private readonly migrationCache = new WeakMap<SerializedSchema, Result<Migration[], string>>()
|
|
110
111
|
|
|
111
112
|
private constructor(
|
|
112
113
|
public readonly types: {
|
|
@@ -158,10 +159,17 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
|
|
|
158
159
|
}
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
// TODO: use a weakmap to store the result of this function
|
|
162
162
|
public getMigrationsSince(persistedSchema: SerializedSchema): Result<Migration[], string> {
|
|
163
|
+
// Check cache first
|
|
164
|
+
const cached = this.migrationCache.get(persistedSchema)
|
|
165
|
+
if (cached) {
|
|
166
|
+
return cached
|
|
167
|
+
}
|
|
168
|
+
|
|
163
169
|
const upgradeResult = upgradeSchema(persistedSchema)
|
|
164
170
|
if (!upgradeResult.ok) {
|
|
171
|
+
// Cache the error result
|
|
172
|
+
this.migrationCache.set(persistedSchema, upgradeResult)
|
|
165
173
|
return upgradeResult
|
|
166
174
|
}
|
|
167
175
|
const schema = upgradeResult.value
|
|
@@ -178,7 +186,10 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
|
|
|
178
186
|
}
|
|
179
187
|
|
|
180
188
|
if (sequenceIdsToInclude.size === 0) {
|
|
181
|
-
|
|
189
|
+
const result = Result.ok([])
|
|
190
|
+
// Cache the empty result
|
|
191
|
+
this.migrationCache.set(persistedSchema, result)
|
|
192
|
+
return result
|
|
182
193
|
}
|
|
183
194
|
|
|
184
195
|
const allMigrationsToInclude = new Set<MigrationId>()
|
|
@@ -197,7 +208,10 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
|
|
|
197
208
|
const idx = this.migrations[sequenceId].sequence.findIndex((m) => m.id === theirVersionId)
|
|
198
209
|
// todo: better error handling
|
|
199
210
|
if (idx === -1) {
|
|
200
|
-
|
|
211
|
+
const result = Result.err('Incompatible schema?')
|
|
212
|
+
// Cache the error result
|
|
213
|
+
this.migrationCache.set(persistedSchema, result)
|
|
214
|
+
return result
|
|
201
215
|
}
|
|
202
216
|
for (const migration of this.migrations[sequenceId].sequence.slice(idx + 1)) {
|
|
203
217
|
allMigrationsToInclude.add(migration.id)
|
|
@@ -205,7 +219,12 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
|
|
|
205
219
|
}
|
|
206
220
|
|
|
207
221
|
// collect any migrations
|
|
208
|
-
|
|
222
|
+
const result = Result.ok(
|
|
223
|
+
this.sortedMigrations.filter(({ id }) => allMigrationsToInclude.has(id))
|
|
224
|
+
)
|
|
225
|
+
// Cache the result
|
|
226
|
+
this.migrationCache.set(persistedSchema, result)
|
|
227
|
+
return result
|
|
209
228
|
}
|
|
210
229
|
|
|
211
230
|
migratePersistedRecord(
|
|
@@ -263,7 +282,10 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
|
|
|
263
282
|
return { type: 'success', value: record }
|
|
264
283
|
}
|
|
265
284
|
|
|
266
|
-
migrateStoreSnapshot(
|
|
285
|
+
migrateStoreSnapshot(
|
|
286
|
+
snapshot: StoreSnapshot<R>,
|
|
287
|
+
opts?: { mutateInputStore?: boolean }
|
|
288
|
+
): MigrationResult<SerializedStore<R>> {
|
|
267
289
|
let { store } = snapshot
|
|
268
290
|
const migrations = this.getMigrationsSince(snapshot.schema)
|
|
269
291
|
if (!migrations.ok) {
|
|
@@ -276,7 +298,9 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
|
|
|
276
298
|
return { type: 'success', value: store }
|
|
277
299
|
}
|
|
278
300
|
|
|
279
|
-
|
|
301
|
+
if (!opts?.mutateInputStore) {
|
|
302
|
+
store = structuredClone(store)
|
|
303
|
+
}
|
|
280
304
|
|
|
281
305
|
try {
|
|
282
306
|
for (const migration of migrationsToApply) {
|
|
@@ -286,13 +310,13 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
|
|
|
286
310
|
if (!shouldApply) continue
|
|
287
311
|
const result = migration.up!(record as any)
|
|
288
312
|
if (result) {
|
|
289
|
-
store[id as keyof typeof store] =
|
|
313
|
+
store[id as keyof typeof store] = result as any
|
|
290
314
|
}
|
|
291
315
|
}
|
|
292
316
|
} else if (migration.scope === 'store') {
|
|
293
317
|
const result = migration.up!(store)
|
|
294
318
|
if (result) {
|
|
295
|
-
store =
|
|
319
|
+
store = result as any
|
|
296
320
|
}
|
|
297
321
|
} else {
|
|
298
322
|
exhaustiveSwitchError(migration)
|
|
@@ -325,6 +349,7 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
|
|
|
325
349
|
|
|
326
350
|
/**
|
|
327
351
|
* @deprecated This is only here for legacy reasons, don't use it unless you have david's blessing!
|
|
352
|
+
* @internal
|
|
328
353
|
*/
|
|
329
354
|
serializeEarliestVersion(): SerializedSchema {
|
|
330
355
|
return {
|
package/src/lib/migrate.ts
CHANGED
|
@@ -2,45 +2,6 @@ import { assert, objectMapEntries } from '@tldraw/utils'
|
|
|
2
2
|
import { UnknownRecord } from './BaseRecord'
|
|
3
3
|
import { SerializedStore } from './Store'
|
|
4
4
|
|
|
5
|
-
let didWarn = false
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* @public
|
|
9
|
-
* @deprecated use `createShapePropsMigrationSequence` instead. See [the docs](https://tldraw.dev/docs/persistence#Updating-legacy-shape-migrations-defineMigrations) for how to migrate.
|
|
10
|
-
*/
|
|
11
|
-
export function defineMigrations(opts: {
|
|
12
|
-
firstVersion?: number
|
|
13
|
-
currentVersion?: number
|
|
14
|
-
migrators?: Record<number, LegacyMigration>
|
|
15
|
-
subTypeKey?: string
|
|
16
|
-
subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo>
|
|
17
|
-
}): LegacyMigrations {
|
|
18
|
-
const { currentVersion, firstVersion, migrators = {}, subTypeKey, subTypeMigrations } = opts
|
|
19
|
-
if (!didWarn) {
|
|
20
|
-
console.warn(
|
|
21
|
-
`The 'defineMigrations' function is deprecated and will be removed in a future release. Use the new migrations API instead. See the migration guide for more info: https://tldraw.dev/docs/persistence#Updating-legacy-shape-migrations-defineMigrations`
|
|
22
|
-
)
|
|
23
|
-
didWarn = true
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Some basic guards against impossible version combinations, some of which will be caught by TypeScript
|
|
27
|
-
if (typeof currentVersion === 'number' && typeof firstVersion === 'number') {
|
|
28
|
-
if ((currentVersion as number) === (firstVersion as number)) {
|
|
29
|
-
throw Error(`Current version is equal to initial version.`)
|
|
30
|
-
} else if (currentVersion < firstVersion) {
|
|
31
|
-
throw Error(`Current version is lower than initial version.`)
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return {
|
|
36
|
-
firstVersion: (firstVersion as number) ?? 0, // defaults
|
|
37
|
-
currentVersion: (currentVersion as number) ?? 0, // defaults
|
|
38
|
-
migrators,
|
|
39
|
-
subTypeKey,
|
|
40
|
-
subTypeMigrations,
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
5
|
function squashDependsOn(sequence: Array<Migration | StandaloneDependsOn>): Migration[] {
|
|
45
6
|
const result: Migration[] = []
|
|
46
7
|
for (let i = sequence.length - 1; i >= 0; i--) {
|
|
@@ -199,38 +160,126 @@ export interface MigrationSequence {
|
|
|
199
160
|
sequence: Migration[]
|
|
200
161
|
}
|
|
201
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Sorts migrations using a distance-minimizing topological sort.
|
|
165
|
+
*
|
|
166
|
+
* This function respects two types of dependencies:
|
|
167
|
+
* 1. Implicit sequence dependencies (foo/1 must come before foo/2)
|
|
168
|
+
* 2. Explicit dependencies via `dependsOn` property
|
|
169
|
+
*
|
|
170
|
+
* The algorithm minimizes the total distance between migrations and their explicit
|
|
171
|
+
* dependencies in the final ordering, while maintaining topological correctness.
|
|
172
|
+
* This means when migration A depends on migration B, A will be scheduled as close
|
|
173
|
+
* as possible to B (while respecting all constraints).
|
|
174
|
+
*
|
|
175
|
+
* Implementation uses Kahn's algorithm with priority scoring:
|
|
176
|
+
* - Builds dependency graph and calculates in-degrees
|
|
177
|
+
* - Uses priority queue that prioritizes migrations which unblock explicit dependencies
|
|
178
|
+
* - Processes migrations in urgency order while maintaining topological constraints
|
|
179
|
+
* - Detects cycles by ensuring all migrations are processed
|
|
180
|
+
*
|
|
181
|
+
* @param migrations - Array of migrations to sort
|
|
182
|
+
* @returns Sorted array of migrations in execution order
|
|
183
|
+
*/
|
|
202
184
|
export function sortMigrations(migrations: Migration[]): Migration[] {
|
|
203
|
-
|
|
204
|
-
const byId = new Map(migrations.map((m) => [m.id, m]))
|
|
205
|
-
const isProcessing = new Set<MigrationId>()
|
|
185
|
+
if (migrations.length === 0) return []
|
|
206
186
|
|
|
207
|
-
|
|
187
|
+
// Build dependency graph and calculate in-degrees
|
|
188
|
+
const byId = new Map(migrations.map((m) => [m.id, m]))
|
|
189
|
+
const dependents = new Map<MigrationId, Set<MigrationId>>() // who depends on this
|
|
190
|
+
const inDegree = new Map<MigrationId, number>()
|
|
191
|
+
const explicitDeps = new Map<MigrationId, Set<MigrationId>>() // explicit dependsOn relationships
|
|
208
192
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
193
|
+
// Initialize
|
|
194
|
+
for (const m of migrations) {
|
|
195
|
+
inDegree.set(m.id, 0)
|
|
196
|
+
dependents.set(m.id, new Set())
|
|
197
|
+
explicitDeps.set(m.id, new Set())
|
|
198
|
+
}
|
|
212
199
|
|
|
200
|
+
// Add implicit sequence dependencies and explicit dependencies
|
|
201
|
+
for (const m of migrations) {
|
|
213
202
|
const { version, sequenceId } = parseMigrationId(m.id)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
203
|
+
|
|
204
|
+
// Implicit dependency on previous in sequence
|
|
205
|
+
const prevId = `${sequenceId}/${version - 1}` as MigrationId
|
|
206
|
+
if (byId.has(prevId)) {
|
|
207
|
+
dependents.get(prevId)!.add(m.id)
|
|
208
|
+
inDegree.set(m.id, inDegree.get(m.id)! + 1)
|
|
217
209
|
}
|
|
218
210
|
|
|
211
|
+
// Explicit dependencies
|
|
219
212
|
if (m.dependsOn) {
|
|
220
|
-
for (const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
213
|
+
for (const depId of m.dependsOn) {
|
|
214
|
+
if (byId.has(depId)) {
|
|
215
|
+
dependents.get(depId)!.add(m.id)
|
|
216
|
+
explicitDeps.get(m.id)!.add(depId)
|
|
217
|
+
inDegree.set(m.id, inDegree.get(m.id)! + 1)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Priority queue: migrations ready to process (in-degree 0)
|
|
224
|
+
const ready = migrations.filter((m) => inDegree.get(m.id) === 0)
|
|
225
|
+
const result: Migration[] = []
|
|
226
|
+
const processed = new Set<MigrationId>()
|
|
227
|
+
|
|
228
|
+
while (ready.length > 0) {
|
|
229
|
+
// Calculate urgency scores for ready migrations and pick the best one
|
|
230
|
+
let bestCandidate: Migration | undefined
|
|
231
|
+
let bestCandidateScore = -Infinity
|
|
232
|
+
|
|
233
|
+
for (const m of ready) {
|
|
234
|
+
let urgencyScore = 0
|
|
235
|
+
|
|
236
|
+
for (const depId of dependents.get(m.id) || []) {
|
|
237
|
+
if (!processed.has(depId)) {
|
|
238
|
+
// Priority 1: Count all unprocessed dependents (to break ties)
|
|
239
|
+
urgencyScore += 1
|
|
240
|
+
|
|
241
|
+
// Priority 2: If this migration is explicitly depended on by others, boost priority
|
|
242
|
+
if (explicitDeps.get(depId)!.has(m.id)) {
|
|
243
|
+
urgencyScore += 100
|
|
244
|
+
}
|
|
224
245
|
}
|
|
225
246
|
}
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
urgencyScore > bestCandidateScore ||
|
|
250
|
+
// Tiebreaker: prefer lower sequence/version
|
|
251
|
+
(urgencyScore === bestCandidateScore && m.id.localeCompare(bestCandidate?.id ?? '') < 0)
|
|
252
|
+
) {
|
|
253
|
+
bestCandidate = m
|
|
254
|
+
bestCandidateScore = urgencyScore
|
|
255
|
+
}
|
|
226
256
|
}
|
|
227
257
|
|
|
228
|
-
|
|
229
|
-
|
|
258
|
+
const nextMigration = bestCandidate!
|
|
259
|
+
ready.splice(ready.indexOf(nextMigration), 1)
|
|
260
|
+
|
|
261
|
+
// Cycle detection - if we have processed everything and still have items left, there's a cycle
|
|
262
|
+
// This is handled by Kahn's algorithm naturally - if we finish with items unprocessed, there's a cycle
|
|
263
|
+
|
|
264
|
+
// Process this migration
|
|
265
|
+
result.push(nextMigration)
|
|
266
|
+
processed.add(nextMigration.id)
|
|
267
|
+
|
|
268
|
+
// Update in-degrees and add newly ready migrations
|
|
269
|
+
for (const depId of dependents.get(nextMigration.id) || []) {
|
|
270
|
+
if (!processed.has(depId)) {
|
|
271
|
+
inDegree.set(depId, inDegree.get(depId)! - 1)
|
|
272
|
+
if (inDegree.get(depId) === 0) {
|
|
273
|
+
ready.push(byId.get(depId)!)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
230
277
|
}
|
|
231
278
|
|
|
232
|
-
for
|
|
233
|
-
|
|
279
|
+
// Check for cycles - if we didn't process all migrations, there's a cycle
|
|
280
|
+
if (result.length !== migrations.length) {
|
|
281
|
+
const unprocessed = migrations.filter((m) => !processed.has(m.id))
|
|
282
|
+
assert(false, `Circular dependency in migrations: ${unprocessed[0].id}`)
|
|
234
283
|
}
|
|
235
284
|
|
|
236
285
|
return result
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { react, transaction } from '@tldraw/state'
|
|
2
|
+
import { vi } from 'vitest'
|
|
2
3
|
import { AtomMap } from '../AtomMap'
|
|
3
4
|
|
|
4
5
|
describe('AtomMap', () => {
|
|
@@ -11,7 +12,7 @@ describe('AtomMap', () => {
|
|
|
11
12
|
})
|
|
12
13
|
|
|
13
14
|
function testReactor(name: string, fn: () => any) {
|
|
14
|
-
const cb =
|
|
15
|
+
const cb = vi.fn(fn)
|
|
15
16
|
const cleanup = react(name, cb)
|
|
16
17
|
cleanupFns.push(() => cleanup())
|
|
17
18
|
return cb
|
|
@@ -26,7 +26,7 @@ describe('dependsOn', () => {
|
|
|
26
26
|
}
|
|
27
27
|
)
|
|
28
28
|
}).toThrowErrorMatchingInlineSnapshot(
|
|
29
|
-
`
|
|
29
|
+
`[Error: Migration 'foo/1' depends on missing migration 'bar/1']`
|
|
30
30
|
)
|
|
31
31
|
})
|
|
32
32
|
|
|
@@ -108,7 +108,7 @@ describe('standalone dependsOn', () => {
|
|
|
108
108
|
}
|
|
109
109
|
)
|
|
110
110
|
}).toThrowErrorMatchingInlineSnapshot(
|
|
111
|
-
`
|
|
111
|
+
`[Error: Migration 'foo/1' depends on missing migration 'bar/1']`
|
|
112
112
|
)
|
|
113
113
|
})
|
|
114
114
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-deprecated */
|
|
2
|
+
import { assert } from '@tldraw/utils'
|
|
3
|
+
import {
|
|
4
|
+
BaseRecord,
|
|
5
|
+
Migration,
|
|
6
|
+
RecordId,
|
|
7
|
+
createMigrationIds,
|
|
8
|
+
createMigrationSequence,
|
|
9
|
+
createRecordType,
|
|
10
|
+
} from '../../'
|
|
11
|
+
import { StoreSchema } from '../StoreSchema'
|
|
12
|
+
|
|
13
|
+
interface TestRecord extends BaseRecord<'test', RecordId<TestRecord>> {
|
|
14
|
+
name: string
|
|
15
|
+
version: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('StoreSchema migration caching', () => {
|
|
19
|
+
// Create migration IDs
|
|
20
|
+
const TestVersions = createMigrationIds('com.tldraw.test', {
|
|
21
|
+
AddVersion: 1,
|
|
22
|
+
UpdateVersion: 2,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Create a simple schema with migrations
|
|
26
|
+
const createTestSchema = (version: number) => {
|
|
27
|
+
const TestRecordType = createRecordType<TestRecord>('test', {
|
|
28
|
+
scope: 'document',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const sequence: Migration[] = []
|
|
32
|
+
|
|
33
|
+
if (version > 1) {
|
|
34
|
+
sequence.push({
|
|
35
|
+
id: TestVersions.AddVersion,
|
|
36
|
+
scope: 'record',
|
|
37
|
+
up: (record: any) => {
|
|
38
|
+
// Mutate the record in place
|
|
39
|
+
record.version = 2
|
|
40
|
+
// Don't return anything
|
|
41
|
+
},
|
|
42
|
+
down: (record: any) => {
|
|
43
|
+
record.version = 1
|
|
44
|
+
// Don't return anything
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (version > 2) {
|
|
50
|
+
sequence.push({
|
|
51
|
+
id: TestVersions.UpdateVersion,
|
|
52
|
+
scope: 'record',
|
|
53
|
+
up: (record: any) => {
|
|
54
|
+
record.version = 3
|
|
55
|
+
// Don't return anything
|
|
56
|
+
},
|
|
57
|
+
down: (record: any) => {
|
|
58
|
+
record.version = 2
|
|
59
|
+
// Don't return anything
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const schema = StoreSchema.create(
|
|
65
|
+
{
|
|
66
|
+
test: TestRecordType,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
migrations: [createMigrationSequence({ sequenceId: 'com.tldraw.test', sequence })],
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return schema
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
it('should cache migration results and return same array reference', () => {
|
|
77
|
+
const schema = createTestSchema(3)
|
|
78
|
+
const oldSchema = schema.serializeEarliestVersion()
|
|
79
|
+
|
|
80
|
+
// First call should create the migrations array
|
|
81
|
+
const migrations1 = schema.getMigrationsSince(oldSchema)
|
|
82
|
+
assert(migrations1.ok)
|
|
83
|
+
expect(migrations1.value).toHaveLength(2)
|
|
84
|
+
|
|
85
|
+
// Second call should return the same array reference (cached)
|
|
86
|
+
const migrations2 = schema.getMigrationsSince(oldSchema)
|
|
87
|
+
assert(migrations2.ok)
|
|
88
|
+
expect(migrations2.value).toBe(migrations1.value) // Same array reference
|
|
89
|
+
|
|
90
|
+
// Third call should also return the same array reference
|
|
91
|
+
const migrations3 = schema.getMigrationsSince(oldSchema)
|
|
92
|
+
assert(migrations3.ok)
|
|
93
|
+
expect(migrations3.value).toBe(migrations1.value)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should not cache when schema versions are different', () => {
|
|
97
|
+
const schema = createTestSchema(3)
|
|
98
|
+
const oldSchema = schema.serializeEarliestVersion()
|
|
99
|
+
|
|
100
|
+
// Call with original schema
|
|
101
|
+
const migrations1 = schema.getMigrationsSince(oldSchema)
|
|
102
|
+
expect(migrations1.ok).toBe(true)
|
|
103
|
+
if (!migrations1.ok) throw new Error('Expected migrations1 to be ok')
|
|
104
|
+
|
|
105
|
+
// Create a different schema version by using a schema with version 2
|
|
106
|
+
const schemaV2 = createTestSchema(2)
|
|
107
|
+
const schemaV2Serialized = schemaV2.serializeEarliestVersion()
|
|
108
|
+
const migrations2 = schema.getMigrationsSince(schemaV2Serialized)
|
|
109
|
+
expect(migrations2.ok).toBe(true)
|
|
110
|
+
if (!migrations2.ok) throw new Error('Expected migrations2 to be ok')
|
|
111
|
+
|
|
112
|
+
// Should be different arrays (no cache hit)
|
|
113
|
+
expect(migrations2.value).not.toBe(migrations1.value)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should handle mutateInputStore: true with migrators that return void', () => {
|
|
117
|
+
const schema = createTestSchema(3)
|
|
118
|
+
const oldSchema = schema.serializeEarliestVersion()
|
|
119
|
+
|
|
120
|
+
const store = {
|
|
121
|
+
test1: {
|
|
122
|
+
id: 'test1',
|
|
123
|
+
name: 'Test 1',
|
|
124
|
+
version: 1,
|
|
125
|
+
typeName: 'test',
|
|
126
|
+
},
|
|
127
|
+
test2: {
|
|
128
|
+
id: 'test2',
|
|
129
|
+
name: 'Test 2',
|
|
130
|
+
version: 1,
|
|
131
|
+
typeName: 'test',
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Test with mutateInputStore: true
|
|
136
|
+
const result1 = schema.migrateStoreSnapshot(
|
|
137
|
+
{ store, schema: oldSchema },
|
|
138
|
+
{ mutateInputStore: true }
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
assert(result1.type === 'success')
|
|
142
|
+
expect((result1.value as any).test1.version).toBe(3)
|
|
143
|
+
expect((result1.value as any).test2.version).toBe(3)
|
|
144
|
+
|
|
145
|
+
// The input store should be mutated in place
|
|
146
|
+
expect(result1.value).toBe(store)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should handle mutateInputStore: false with migrators that return void', () => {
|
|
150
|
+
const schema = createTestSchema(3)
|
|
151
|
+
const oldSchema = schema.serializeEarliestVersion()
|
|
152
|
+
|
|
153
|
+
const store = {
|
|
154
|
+
test1: {
|
|
155
|
+
id: 'test1',
|
|
156
|
+
name: 'Test 1',
|
|
157
|
+
version: 1,
|
|
158
|
+
typeName: 'test',
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Test with mutateInputStore: false (default)
|
|
163
|
+
const result = schema.migrateStoreSnapshot({ store, schema: oldSchema })
|
|
164
|
+
|
|
165
|
+
assert(result.type === 'success')
|
|
166
|
+
expect((result.value as any).test1.version).toBe(3)
|
|
167
|
+
|
|
168
|
+
// The input store should NOT be mutated
|
|
169
|
+
expect(store.test1.version).toBe(1)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('should handle empty migration list caching', () => {
|
|
173
|
+
const schema = createTestSchema(1) // No migrations
|
|
174
|
+
const oldSchema = schema.serializeEarliestVersion()
|
|
175
|
+
|
|
176
|
+
// First call
|
|
177
|
+
const migrations1 = schema.getMigrationsSince(oldSchema)
|
|
178
|
+
assert(migrations1.ok)
|
|
179
|
+
|
|
180
|
+
expect(migrations1.value).toHaveLength(0)
|
|
181
|
+
|
|
182
|
+
// Second call should return same array reference
|
|
183
|
+
const migrations2 = schema.getMigrationsSince(oldSchema)
|
|
184
|
+
assert(migrations2.ok)
|
|
185
|
+
expect(migrations2.value).toBe(migrations1.value)
|
|
186
|
+
expect(migrations2.value).toHaveLength(0)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('should handle incompatible schema caching', () => {
|
|
190
|
+
const schema = createTestSchema(3)
|
|
191
|
+
const incompatibleSchema = {
|
|
192
|
+
schemaVersion: 1 as const,
|
|
193
|
+
storeVersion: 1,
|
|
194
|
+
recordVersions: {
|
|
195
|
+
test: {
|
|
196
|
+
version: 999, // Much higher version than what we support
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// First call should fail
|
|
202
|
+
const migrations1 = schema.getMigrationsSince(incompatibleSchema)
|
|
203
|
+
expect(migrations1.ok).toBe(false)
|
|
204
|
+
|
|
205
|
+
// Second call should also fail (but might be cached)
|
|
206
|
+
const migrations2 = schema.getMigrationsSince(incompatibleSchema)
|
|
207
|
+
expect(migrations2.ok).toBe(false)
|
|
208
|
+
})
|
|
209
|
+
})
|