Tldraw/packages/store/src/lib/migrate.ts

291 wiersze
8.4 KiB
TypeScript

import { assert, objectMapEntries } from '@tldraw/utils'
import { UnknownRecord } from './BaseRecord'
import { SerializedStore } from './Store'
let didWarn = false
/**
* @public
* @deprecated use `createShapePropsMigrationSequence` instead. See [the docs](https://tldraw.dev/docs/persistence#Updating-legacy-shape-migrations-defineMigrations) for how to migrate.
*/
export function defineMigrations(opts: {
firstVersion?: number
currentVersion?: number
migrators?: Record<number, LegacyMigration>
subTypeKey?: string
subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo>
}): LegacyMigrations {
const { currentVersion, firstVersion, migrators = {}, subTypeKey, subTypeMigrations } = opts
if (!didWarn) {
console.warn(
`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`
)
didWarn = true
}
// Some basic guards against impossible version combinations, some of which will be caught by TypeScript
if (typeof currentVersion === 'number' && typeof firstVersion === 'number') {
if ((currentVersion as number) === (firstVersion as number)) {
throw Error(`Current version is equal to initial version.`)
} else if (currentVersion < firstVersion) {
throw Error(`Current version is lower than initial version.`)
}
}
return {
firstVersion: (firstVersion as number) ?? 0, // defaults
currentVersion: (currentVersion as number) ?? 0, // defaults
migrators,
subTypeKey,
subTypeMigrations,
}
}
function squashDependsOn(sequence: Array<Migration | StandaloneDependsOn>): Migration[] {
const result: Migration[] = []
for (let i = sequence.length - 1; i >= 0; i--) {
const elem = sequence[i]
if (!('id' in elem)) {
const dependsOn = elem.dependsOn
const prev = result[0]
if (prev) {
result[0] = {
...prev,
dependsOn: dependsOn.concat(prev.dependsOn ?? []),
}
}
} else {
result.unshift(elem)
}
}
return result
}
/**
* Creates a migration sequence.
* See the [migration guide](https://tldraw.dev/docs/persistence#Migrations) for more info on how to use this API.
* @public
*/
export function createMigrationSequence({
sequence,
sequenceId,
retroactive = true,
}: {
sequenceId: string
retroactive?: boolean
sequence: Array<Migration | StandaloneDependsOn>
}): MigrationSequence {
const migrations: MigrationSequence = {
sequenceId,
retroactive,
sequence: squashDependsOn(sequence),
}
validateMigrations(migrations)
return migrations
}
/**
* Creates a named set of migration ids given a named set of version numbers and a sequence id.
*
* See the [migration guide](https://tldraw.dev/docs/persistence#Migrations) for more info on how to use this API.
* @public
* @public
*/
export function createMigrationIds<
const ID extends string,
const Versions extends Record<string, number>,
>(sequenceId: ID, versions: Versions): { [K in keyof Versions]: `${ID}/${Versions[K]}` } {
return Object.fromEntries(
objectMapEntries(versions).map(([key, version]) => [key, `${sequenceId}/${version}`] as const)
) as any
}
/** @internal */
export function createRecordMigrationSequence(opts: {
recordType: string
filter?: (record: UnknownRecord) => boolean
retroactive?: boolean
sequenceId: string
sequence: Omit<Extract<Migration, { scope: 'record' }>, 'scope'>[]
}): MigrationSequence {
const sequenceId = opts.sequenceId
return createMigrationSequence({
sequenceId,
retroactive: opts.retroactive ?? true,
sequence: opts.sequence.map((m) =>
'id' in m
? {
...m,
scope: 'record',
filter: (r: UnknownRecord) =>
r.typeName === opts.recordType &&
(m.filter?.(r) ?? true) &&
(opts.filter?.(r) ?? true),
}
: m
),
})
}
/** @public */
export type LegacyMigration<Before = any, After = any> = {
up: (oldState: Before) => After
down: (newState: After) => Before
}
/** @public */
export type MigrationId = `${string}/${number}`
/** @public */
export type StandaloneDependsOn = {
readonly dependsOn: readonly MigrationId[]
}
/** @public */
export type Migration = {
readonly id: MigrationId
readonly dependsOn?: readonly MigrationId[] | undefined
} & (
| {
readonly scope: 'record'
readonly filter?: (record: UnknownRecord) => boolean
readonly up: (oldState: UnknownRecord) => void | UnknownRecord
readonly down?: (newState: UnknownRecord) => void | UnknownRecord
}
| {
readonly scope: 'store'
readonly up: (
oldState: SerializedStore<UnknownRecord>
) => void | SerializedStore<UnknownRecord>
readonly down?: (
newState: SerializedStore<UnknownRecord>
) => void | SerializedStore<UnknownRecord>
}
)
interface LegacyBaseMigrationsInfo {
firstVersion: number
currentVersion: number
migrators: { [version: number]: LegacyMigration }
}
/** @public */
export interface LegacyMigrations extends LegacyBaseMigrationsInfo {
subTypeKey?: string
subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo>
}
/** @public */
export interface MigrationSequence {
sequenceId: string
/**
* retroactive should be true if the migrations should be applied to snapshots that were created before
* this migration sequence was added to the schema.
*
* In general:
*
* - retroactive should be true when app developers create their own new migration sequences.
* - retroactive should be false when library developers ship a migration sequence. When you install a library for the first time, any migrations that were added in the library before that point should generally _not_ be applied to your existing data.
*/
retroactive: boolean
sequence: Migration[]
}
export function sortMigrations(migrations: Migration[]): Migration[] {
// we do a topological sort using dependsOn and implicit dependencies between migrations in the same sequence
const byId = new Map(migrations.map((m) => [m.id, m]))
const isProcessing = new Set<MigrationId>()
const result: Migration[] = []
function process(m: Migration) {
assert(!isProcessing.has(m.id), `Circular dependency in migrations: ${m.id}`)
isProcessing.add(m.id)
const { version, sequenceId } = parseMigrationId(m.id)
const parent = byId.get(`${sequenceId}/${version - 1}`)
if (parent) {
process(parent)
}
if (m.dependsOn) {
for (const dep of m.dependsOn) {
const depMigration = byId.get(dep)
if (depMigration) {
process(depMigration)
}
}
}
byId.delete(m.id)
result.push(m)
}
for (const m of byId.values()) {
process(m)
}
return result
}
/** @internal */
export function parseMigrationId(id: MigrationId): { sequenceId: string; version: number } {
const [sequenceId, version] = id.split('/')
return { sequenceId, version: parseInt(version) }
}
function validateMigrationId(id: string, expectedSequenceId?: string) {
if (expectedSequenceId) {
assert(
id.startsWith(expectedSequenceId + '/'),
`Every migration in sequence '${expectedSequenceId}' must have an id starting with '${expectedSequenceId}/'. Got invalid id: '${id}'`
)
}
assert(id.match(/^(.*?)\/(0|[1-9]\d*)$/), `Invalid migration id: '${id}'`)
}
export function validateMigrations(migrations: MigrationSequence) {
assert(
!migrations.sequenceId.includes('/'),
`sequenceId cannot contain a '/', got ${migrations.sequenceId}`
)
assert(migrations.sequenceId.length, 'sequenceId must be a non-empty string')
if (migrations.sequence.length === 0) {
return
}
validateMigrationId(migrations.sequence[0].id, migrations.sequenceId)
let n = parseMigrationId(migrations.sequence[0].id).version
assert(
n === 1,
`Expected the first migrationId to be '${migrations.sequenceId}/1' but got '${migrations.sequence[0].id}'`
)
for (let i = 1; i < migrations.sequence.length; i++) {
const id = migrations.sequence[i].id
validateMigrationId(id, migrations.sequenceId)
const m = parseMigrationId(id).version
assert(
m === n + 1,
`Migration id numbers must increase in increments of 1, expected ${migrations.sequenceId}/${n + 1} but got '${migrations.sequence[i].id}'`
)
n = m
}
}
/** @public */
export type MigrationResult<T> =
| { type: 'success'; value: T }
| { type: 'error'; reason: MigrationFailureReason }
/** @public */
export enum MigrationFailureReason {
IncompatibleSubtype = 'incompatible-subtype',
UnknownType = 'unknown-type',
TargetVersionTooNew = 'target-version-too-new',
TargetVersionTooOld = 'target-version-too-old',
MigrationError = 'migration-error',
UnrecognizedSubtype = 'unrecognized-subtype',
}