feat(pfapi): outline single model versions and migrations

This commit is contained in:
Johannes Millan 2025-04-16 13:15:55 +02:00
parent 98e081eb44
commit ac6003900b
7 changed files with 122 additions and 24 deletions

View file

@ -1,10 +1,18 @@
import { AllSyncModels, ModelCfgs } from '../pfapi.model';
import {
AllSyncModels,
ModelCfgs,
ModelCfgToModelCtrl,
ModelVersionMap,
} from '../pfapi.model';
import { pfLog } from '../util/log';
import { ImpossibleError, ModelMigrationError } from '../errors/errors';
import { Pfapi } from '../pfapi';
export class MigrationService<MD extends ModelCfgs> {
constructor(private _pfapiMain: Pfapi<MD>) {}
constructor(
private _pfapiMain: Pfapi<MD>,
public m: ModelCfgToModelCtrl<MD>,
) {}
async checkAndMigrateLocalDB(): Promise<void> {
const meta = await this._pfapiMain.metaModel.load();
@ -15,6 +23,7 @@ export class MigrationService<MD extends ModelCfgs> {
const r = await this.migrate(
meta.crossModelVersion,
await this._pfapiMain.getAllSyncModelData(true),
meta.modelVersions,
);
if (r.wasMigrated) {
const { dataAfter, versionAfter } = r;
@ -45,6 +54,7 @@ export class MigrationService<MD extends ModelCfgs> {
async migrate(
dataInCrossModelVersion: number,
dataIn: AllSyncModels<MD>,
modelVersionMap: ModelVersionMap,
): Promise<{
dataAfter: AllSyncModels<MD>;
versionAfter: number;
@ -52,6 +62,8 @@ export class MigrationService<MD extends ModelCfgs> {
}> {
const cfg = this._pfapiMain.cfg;
const codeModelVersion = cfg?.crossModelVersion;
// const singleModelsToMigrate = Object.keys(modelVersionMap).filter();
if (
typeof codeModelVersion !== 'number' ||
dataInCrossModelVersion === codeModelVersion
@ -106,6 +118,7 @@ export class MigrationService<MD extends ModelCfgs> {
migrationsToRun.forEach((migrateFn) => {
migratedData = migrateFn(migratedData);
});
return {
dataAfter: migratedData,
versionAfter: codeModelVersion,
@ -115,12 +128,25 @@ export class MigrationService<MD extends ModelCfgs> {
pfLog(0, `Migration functions failed to execute`, { error });
throw new ModelMigrationError('Error running migration functions', error);
}
// TODO single model migration
// const modelIds = Object.keys(this.m);
// for (const modelId of modelIds) {
// const modelCtrl = this.m[modelId];
//
// }
}
// private _getSingleModelIdsToMigrate(modelVersionMap: ModelVersionMap): string[] {
// return Object.keys(modelVersionMap).filter((modelId) => {
// const modelCtrl = this.m[modelId];
// // if (!modelCtrl) {
// // throw new ImpossibleError(`Model controller not found for ${modelId}`);
// // }
// return modelCtrl.modelCfg.modelVersion > modelVersionMap[modelId];
// });
// }
//
// private _migrateSingleModels(migratedData: any) {
// const modelIds = Object.keys(this.m);
// for (const modelId of modelIds) {
// const modelCtrl = this.m[modelId];
// // TODO fix
// // @ts-ignore
// migratedData[modelId] = modelCtrl.migrate(migratedData, modelVersionMap[modelId]);
// }
// }
}

View file

@ -39,10 +39,19 @@ export class ModelCtrl<MT extends ModelBase> {
save(
data: MT,
p?: { isUpdateRevAndLastUpdate: boolean; isIgnoreDBLock?: boolean },
sourceModelVersion?: number,
): Promise<unknown> {
this._inMemoryData = data;
pfLog(2, `${ModelCtrl.name}.${this.save.name}()`, this.modelId, p, data);
if (typeof sourceModelVersion === 'string') {
sourceModelVersion = +sourceModelVersion;
}
if (typeof sourceModelVersion === 'number') {
console.log(sourceModelVersion);
data = this.migrate(data, sourceModelVersion);
}
// Validate data if validator is available
if (this.modelCfg.validate && !this.modelCfg.validate(data).success) {
if (this.modelCfg.repair) {
@ -67,6 +76,32 @@ export class ModelCtrl<MT extends ModelBase> {
return this._db.save(this.modelId, data, isIgnoreDBLock);
}
// TODO
/**
* Saves the model data to database
* @param data Model data to save
* @returns migrated data
*/
migrate<T>(data: MT | T, sourceModelVersion: number): MT {
if (
typeof this.modelCfg.migrations === 'object' &&
this.modelCfg.migrations !== null
) {
Object.keys(this.modelCfg.migrations)
.sort()
.forEach((key) => {
const version = +key;
console.log(version);
if (version > sourceModelVersion) {
alert('MIGRATE');
console.log('migration script version', version);
data = this.modelCfg.migrations![key](data);
}
});
}
return data as MT;
}
/**
* Updates part of the model data
* @param data Partial data to update

View file

@ -21,18 +21,19 @@ type SerializableArray = Array<Serializable>;
export type ModelBase = SerializableObject | SerializableArray | unknown;
export type ModelMigrateFn<T> = <F>(modelData: F | T) => T;
export interface ModelMigrations<T> {
[version: number]: ModelMigrateFn<T>;
}
export interface ModelCfg<T extends ModelBase> {
modelVersion: number;
isLocalOnly?: boolean;
// migrations?: {
// [version: string]: (arg: T) => T;
// };
// TODO fix typing
// migrations?: Record<string, (arg: T) => T>;
isAlwaysReApplyOldMigrations?: boolean;
debounceDbWrite?: number;
isMainFileModel?: boolean;
migrations?: ModelMigrations<T>;
validate?: <R>(data: R | T) => IValidation<R | T>;
repair?: <R>(data: R | unknown | any) => T;

View file

@ -7,6 +7,7 @@ import {
ModelBase,
ModelCfgs,
ModelCfgToModelCtrl,
ModelVersionMap,
PfapiBaseCfg,
PrivateCfgByProviderId,
} from './pfapi.model';
@ -96,7 +97,7 @@ export class Pfapi<const MD extends ModelCfgs> {
sp.privateCfg = new SyncProviderPrivateCfgStore(sp.id, this.db, this.ev);
});
this.migrationService = new MigrationService<MD>(this);
this.migrationService = new MigrationService<MD>(this, this.m);
this._syncService = new SyncService<MD>(
this.m,
@ -267,6 +268,7 @@ export class Pfapi<const MD extends ModelCfgs> {
return await this.importAllSycModelData({
data: backup.data,
crossModelVersion: backup.crossModelVersion,
modelVersionMap: backup.modelVersions,
// TODO maybe also make model versions work
isBackupData: true,
isAttemptRepair: true,
@ -277,6 +279,7 @@ export class Pfapi<const MD extends ModelCfgs> {
async importAllSycModelData({
data,
crossModelVersion,
modelVersionMap = {},
isAttemptRepair = false,
isBackupData = false,
isSkipLegacyWarnings = false,
@ -284,6 +287,7 @@ export class Pfapi<const MD extends ModelCfgs> {
}: {
data: AllSyncModels<MD>;
crossModelVersion: number;
modelVersionMap?: ModelVersionMap;
isAttemptRepair?: boolean;
isBackupData?: boolean;
isSkipLegacyWarnings?: boolean;
@ -291,7 +295,11 @@ export class Pfapi<const MD extends ModelCfgs> {
}): Promise<void> {
pfLog(2, `${this.importAllSycModelData.name}()`, { data, cfg: this.cfg });
const { dataAfter } = await this.migrationService.migrate(crossModelVersion, data);
const { dataAfter } = await this.migrationService.migrate(
crossModelVersion,
data,
modelVersionMap,
);
data = dataAfter;
if (this.cfg?.validate) {
@ -345,10 +353,14 @@ export class Pfapi<const MD extends ModelCfgs> {
throw new ModelIdWithoutCtrlError(modelId, modelData);
}
return modelCtrl.save(modelData, {
isUpdateRevAndLastUpdate: false,
isIgnoreDBLock: true,
});
return modelCtrl.save(
modelData,
{
isUpdateRevAndLastUpdate: false,
isIgnoreDBLock: true,
},
modelVersionMap[modelId],
);
});
await Promise.all(promises);
this.db.unlock();

View file

@ -17,6 +17,7 @@ import {
MainModelData,
ModelCfgs,
ModelCfgToModelCtrl,
ModelVersionMap,
RemoteMeta,
RevMap,
} from '../pfapi.model';
@ -155,18 +156,24 @@ export class ModelSyncService<MD extends ModelCfgs> {
*
* @param toUpdate - Array of model IDs to update
* @param toDelete - Array of model IDs to delete
* @param modelVersionMap - Map of the model versions
* @param dataMap - Map of model data indexed by model ID
* @returns Promise resolving once all operations are complete
*/
async updateLocalUpdated(
toUpdate: string[],
toDelete: string[],
modelVersionMap: ModelVersionMap,
dataMap: { [key: string]: unknown },
): Promise<unknown> {
return await Promise.all([
...toUpdate.map((modelId) =>
// NOTE: needs to be cast to a generic type, since dataMap is a generic object
this._updateLocal(modelId, dataMap[modelId] as ExtractModelCfgType<MD[string]>),
this._updateLocal(
modelId,
dataMap[modelId] as ExtractModelCfgType<MD[string]>,
modelVersionMap[modelId],
),
),
...toDelete.map((modelId) => this._removeLocal(modelId)),
]);
@ -179,6 +186,7 @@ export class ModelSyncService<MD extends ModelCfgs> {
*/
async updateLocalMainModelsFromRemoteMetaFile(remote: RemoteMeta): Promise<void> {
const mainModelData = remote.mainModelData;
if (typeof mainModelData === 'object' && mainModelData !== null) {
pfLog(
2,
@ -193,6 +201,7 @@ export class ModelSyncService<MD extends ModelCfgs> {
{
isUpdateRevAndLastUpdate: false,
},
remote.modelVersions[modelId],
);
}
});
@ -275,13 +284,15 @@ export class ModelSyncService<MD extends ModelCfgs> {
*
* @param modelId - The ID of the model to update
* @param modelData - The data to update the model with
* @param sourceModelVersion
* @private
*/
private async _updateLocal<T extends keyof MD>(
modelId: T,
modelData: ExtractModelCfgType<MD[T]>,
sourceModelVersion?: number,
): Promise<void> {
await this.m[modelId].save(modelData);
await this.m[modelId].save(modelData, undefined, sourceModelVersion);
}
/**

View file

@ -377,7 +377,12 @@ export class SyncService<const MD extends ModelCfgs> {
isSkipLegacyWarnings: false,
});
} else {
await this._modelSyncService.updateLocalUpdated(toUpdate, toDelete, dataMap);
await this._modelSyncService.updateLocalUpdated(
toUpdate,
toDelete,
remote.modelVersions,
dataMap,
);
await this._modelSyncService.updateLocalMainModelsFromRemoteMetaFile(remote);
}

View file

@ -100,10 +100,18 @@ export const PFAPI_MODEL_CFGS: PfapiAllModelCfg = {
},
project: {
modelVersion: 1.2,
modelVersion: 1.4,
defaultData: initialProjectState,
isMainFileModel: true,
validate: appDataValidators.project,
migrations: {
// eslint-disable-next-line @typescript-eslint/naming-convention
1.4: (data) => {
alert('Migrating project data to new version');
throw new Error('aaa');
return data as ProjectState;
},
},
},
tag: {
modelVersion: 1,