import { UnknownRecord, isRecord } from './BaseRecord' import { SerializedSchema } from './StoreSchema' type EMPTY_SYMBOL = symbol /** @public */ export function defineMigrations< FirstVersion extends number | EMPTY_SYMBOL = EMPTY_SYMBOL, CurrentVersion extends Exclude | EMPTY_SYMBOL = EMPTY_SYMBOL, >(opts: { firstVersion?: CurrentVersion extends number ? FirstVersion : never currentVersion?: CurrentVersion migrators?: CurrentVersion extends number ? FirstVersion extends number ? CurrentVersion extends FirstVersion ? { [version in Exclude, 0>]: Migration } : { [version in Exclude, FirstVersion>]: Migration } : { [version in Exclude, 0>]: Migration } : never subTypeKey?: string subTypeMigrations?: Record }): Migrations { const { currentVersion, firstVersion, migrators = {}, subTypeKey, subTypeMigrations } = opts // 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, } } /** @public */ export type Migration = { up: (oldState: Before) => After down: (newState: After) => Before } interface BaseMigrationsInfo { firstVersion: number currentVersion: number migrators: { [version: number]: Migration } } /** @public */ export interface Migrations extends BaseMigrationsInfo { subTypeKey?: string subTypeMigrations?: Record } /** @public */ export type MigrationResult = | { 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', } /** @public */ export type RecordVersion = { rootVersion: number; subTypeVersion?: number } /** @public */ export function getRecordVersion( record: UnknownRecord, serializedSchema: SerializedSchema ): RecordVersion { const persistedType = serializedSchema.recordVersions[record.typeName] if (!persistedType) { return { rootVersion: 0 } } if ('subTypeKey' in persistedType) { const subType = record[persistedType.subTypeKey as keyof typeof record] const subTypeVersion = persistedType.subTypeVersions[subType] return { rootVersion: persistedType.version, subTypeVersion } } return { rootVersion: persistedType.version } } /** @public */ export function compareRecordVersions(a: RecordVersion, b: RecordVersion) { if (a.rootVersion > b.rootVersion) { return 1 } if (a.rootVersion < b.rootVersion) { return -1 } if (a.subTypeVersion != null && b.subTypeVersion != null) { if (a.subTypeVersion > b.subTypeVersion) { return 1 } if (a.subTypeVersion < b.subTypeVersion) { return -1 } } return 0 } /** @public */ export function migrateRecord({ record, migrations, fromVersion, toVersion, }: { record: unknown migrations: Migrations fromVersion: number toVersion: number }): MigrationResult { let currentVersion = fromVersion if (!isRecord(record)) throw new Error('[migrateRecord] object is not a record') const { typeName, id, ...others } = record let recordWithoutMeta = others while (currentVersion < toVersion) { const nextVersion = currentVersion + 1 const migrator = migrations.migrators[nextVersion] if (!migrator) { return { type: 'error', reason: MigrationFailureReason.TargetVersionTooNew, } } recordWithoutMeta = migrator.up(recordWithoutMeta) as any currentVersion = nextVersion } while (currentVersion > toVersion) { const nextVersion = currentVersion - 1 const migrator = migrations.migrators[currentVersion] if (!migrator) { return { type: 'error', reason: MigrationFailureReason.TargetVersionTooOld, } } recordWithoutMeta = migrator.down(recordWithoutMeta) as any currentVersion = nextVersion } return { type: 'success', value: { ...recordWithoutMeta, id, typeName } as any, } } /** @public */ export function migrate({ value, migrations, fromVersion, toVersion, }: { value: unknown migrations: Migrations fromVersion: number toVersion: number }): MigrationResult { let currentVersion = fromVersion while (currentVersion < toVersion) { const nextVersion = currentVersion + 1 const migrator = migrations.migrators[nextVersion] if (!migrator) { return { type: 'error', reason: MigrationFailureReason.TargetVersionTooNew, } } value = migrator.up(value) currentVersion = nextVersion } while (currentVersion > toVersion) { const nextVersion = currentVersion - 1 const migrator = migrations.migrators[currentVersion] if (!migrator) { return { type: 'error', reason: MigrationFailureReason.TargetVersionTooOld, } } value = migrator.down(value) currentVersion = nextVersion } return { type: 'success', value: value as T, } } type Range = To extends From ? From : To | Range> type Decrement = n extends 0 ? never : n extends 1 ? 0 : n extends 2 ? 1 : n extends 3 ? 2 : n extends 4 ? 3 : n extends 5 ? 4 : n extends 6 ? 5 : n extends 7 ? 6 : n extends 8 ? 7 : n extends 9 ? 8 : n extends 10 ? 9 : n extends 11 ? 10 : n extends 12 ? 11 : n extends 13 ? 12 : n extends 14 ? 13 : n extends 15 ? 14 : n extends 16 ? 15 : n extends 17 ? 16 : n extends 18 ? 17 : n extends 19 ? 18 : n extends 20 ? 19 : n extends 21 ? 20 : n extends 22 ? 21 : n extends 23 ? 22 : n extends 24 ? 23 : n extends 25 ? 24 : n extends 26 ? 25 : n extends 27 ? 26 : n extends 28 ? 27 : n extends 29 ? 28 : n extends 30 ? 29 : n extends 31 ? 30 : n extends 32 ? 31 : n extends 33 ? 32 : n extends 34 ? 33 : n extends 35 ? 34 : n extends 36 ? 35 : n extends 37 ? 36 : n extends 38 ? 37 : n extends 39 ? 38 : n extends 40 ? 39 : n extends 41 ? 40 : n extends 42 ? 41 : n extends 43 ? 42 : n extends 44 ? 43 : n extends 45 ? 44 : n extends 46 ? 45 : n extends 47 ? 46 : n extends 48 ? 47 : n extends 49 ? 48 : n extends 50 ? 49 : n extends 51 ? 50 : never