import { BaseRecord } from './BaseRecord' import { migrate, migrateRecord, MigrationFailureReason, MigrationResult, Migrations, } from './migrate' import { RecordType } from './RecordType' import { Store } from './Store' /** @public */ export interface SerializedSchema { /** * Schema version is the version for this type you're looking at right now */ schemaVersion: number /** * Store version is the version for the structure of the store. e.g. higher level * structure like removing or renaming a record type. */ storeVersion: number /** * Record versions are the versions for each record type. e.g. adding a new field to a record */ recordVersions: Record< string, | { version: number } | { // subtypes are used for migrating shape and asset props version: number subTypeVersions: Record subTypeKey: string } > } /** @public */ export class StoreSchema { types: Record> = {} constructor( public readonly typeArray: RecordType[], public readonly storeMigrations: Migrations ) { for (const type of typeArray) { this.types[type.typeName] = type } } migratePersistedRecord( record: R, persistedSchema: SerializedSchema, direction: 'up' | 'down' = 'up' ): MigrationResult { const ourType = this.types[record.typeName] const persistedType = persistedSchema.recordVersions[record.typeName] if (!persistedType || !ourType) { return { type: 'error', reason: MigrationFailureReason.UnknownType } } const ourVersion = ourType.config.migrations.currentVersion const persistedVersion = persistedType.version if (ourVersion !== persistedVersion) { const result = direction === 'up' ? migrateRecord({ record, migrations: ourType.config.migrations, fromVersion: persistedVersion, toVersion: ourVersion, }) : migrateRecord({ record, migrations: ourType.config.migrations, fromVersion: ourVersion, toVersion: persistedVersion, }) if (result.type === 'error') { return result } record = result.value } if (!ourType.config.migrations.subTypeKey) { return { type: 'success', value: record } } // we've handled the main version migration, now we need to handle subtypes // subtypes are used by shape and asset types to migrate the props shape, which is configurable // by library consumers. const ourSubTypeMigrations = ourType.config.migrations.subTypeMigrations?.[ record[ourType.config.migrations.subTypeKey as keyof R] as string ] const persistedSubTypeVersion = 'subTypeVersions' in persistedType ? persistedType.subTypeVersions[ record[ourType.config.migrations.subTypeKey as keyof R] as string ] : null // if ourSubTypeMigrations is undefined then we don't have access to the migrations for this subtype // that is almost certainly because we are running on the server and this type was supplied by a 3rd party. // It could also be that we are running in a client that is outdated. Either way, we can't migrate this record // and we need to let the consumer know so they can handle it. if (ourSubTypeMigrations === undefined) { return { type: 'error', reason: MigrationFailureReason.UnrecognizedSubtype } } // if the persistedSubTypeVersion is undefined then the record was either created after the schema // was persisted, or it was created in a different place to where the scehma was persisted. // either way we don't know what to do with it safely, so let's return failure. if (persistedSubTypeVersion == null) { return { type: 'error', reason: MigrationFailureReason.IncompatibleSubtype } } const result = direction === 'up' ? migrateRecord({ record, migrations: ourSubTypeMigrations, fromVersion: persistedSubTypeVersion, toVersion: ourSubTypeMigrations.currentVersion, }) : migrateRecord({ record, migrations: ourSubTypeMigrations, fromVersion: ourSubTypeMigrations.currentVersion, toVersion: persistedSubTypeVersion, }) if (result.type === 'error') { return result } return { type: 'success', value: result.value } } migrateStore(store: Store, persistedSchema: SerializedSchema): MigrationResult { // apply store migrations first const ourStoreVersion = this.storeMigrations.currentVersion const persistedStoreVersion = persistedSchema.storeVersion ?? 0 if (ourStoreVersion < persistedStoreVersion) { return { type: 'error', reason: MigrationFailureReason.TargetVersionTooNew } } if (ourStoreVersion > persistedStoreVersion) { const result = migrate>({ value: store, migrations: this.storeMigrations, fromVersion: persistedStoreVersion, toVersion: ourStoreVersion, }) if (result.type === 'error') { return result } store = result.value } const updated: R[] = [] for (const r of store.allRecords()) { const result = this.migratePersistedRecord(r, persistedSchema) if (result.type === 'error') { return result } else if (result.value && result.value !== r) { updated.push(result.value) } } store.put(updated) return { type: 'success', value: undefined } } serialize(): SerializedSchema { return { schemaVersion: 1, storeVersion: this.storeMigrations.currentVersion, recordVersions: Object.fromEntries( this.typeArray.map((type) => [ type.typeName, type.config.migrations.subTypeKey && type.config.migrations.subTypeMigrations ? { version: type.config.migrations.currentVersion, subTypeKey: type.config.migrations.subTypeKey, subTypeVersions: type.config.migrations.subTypeMigrations ? Object.fromEntries( Object.entries(type.config.migrations.subTypeMigrations).map(([k, v]) => [ k, v.currentVersion, ]) ) : undefined, } : { version: type.config.migrations.currentVersion, }, ]) ), } } serializeEarliestVersion(): SerializedSchema { return { schemaVersion: 1, storeVersion: this.storeMigrations.firstVersion, recordVersions: Object.fromEntries( this.typeArray.map((type) => [ type.typeName, type.config.migrations.subTypeKey && type.config.migrations.subTypeMigrations ? { version: type.config.migrations.firstVersion, subTypeKey: type.config.migrations.subTypeKey, subTypeVersions: type.config.migrations.subTypeMigrations ? Object.fromEntries( Object.entries(type.config.migrations.subTypeMigrations).map(([k, v]) => [ k, v.firstVersion, ]) ) : undefined, } : { version: type.config.migrations.firstVersion, }, ]) ), } } }