diff --git a/.vscode/settings.json b/.vscode/settings.json index 2bfea9d..133b12d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,8 @@ "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "jest.autoRun": {}, + "search.exclude": { ".git": true, ".eslintcache": true, @@ -34,5 +36,5 @@ "*.{css,sass,scss}.d.ts": true }, - "cSpell.words": ["Popconfirm", "Sider"] + "cSpell.words": ["autorun", "Popconfirm", "Sider", "Yadro"] } diff --git a/docs/Migrations.md b/docs/Migrations.md new file mode 100644 index 0000000..6b7b805 --- /dev/null +++ b/docs/Migrations.md @@ -0,0 +1,19 @@ +1. Добавить в `schemas` новую схему `{entity}/migrations/{Name}SchemaV{N}` +2. Добавить в `schemas` в файл `index.ts` новую схему + +```ts +export const schemaMigrations: SchemaMigration[] = [ + ...{ version: N, schema: NameSchemaVN }, +]; +``` + +3. В AbstractFileRepository.ts restore + 1. Парсит json + 2. Проверяет являются ли полученные данные объектом +4. Миграция + 1. Смотрит есть ли `__version` в корне объекта + 2. Если нет, то валидирует с помощью схемы `{Name}SchemaV0`, иначе ищет схему с соответствующей версией + 3. Если версия не самая последняя из списка `schemaMigrations`, то применяет миграцию + 1. Миграция принимает версию V{N} и возвращает v{N+1} + 2. Применяем миграции с N до последней + 4. После того как все миграции прошли, передаем данные в конструктор модели diff --git a/package.json b/package.json index 7d71bb7..0bfa0a1 100644 --- a/package.json +++ b/package.json @@ -231,6 +231,7 @@ "@ant-design/colors": "6.0.0", "@ant-design/icons": "4.6.2", "@sentry/electron": "2.5.0", + "ajv": "^8.8.2", "antd": "4.17.2", "caniuse-lite": "1.0.30001214", "clsx": "^1.1.1", diff --git a/src/base/AbstractFactory.ts b/src/base/AbstractFactory.ts index bdf5cd8..d61beff 100644 --- a/src/base/AbstractFactory.ts +++ b/src/base/AbstractFactory.ts @@ -1,9 +1,11 @@ +// ConstructorParameters + export default abstract class AbstractFactory { create(Model: any, data: any): T { return new Model(data); } - createList(Model: any, data: any): T[] { + createList(Model: M, data: any): T[] { let items: T[] = []; data.forEach((json: any) => { diff --git a/src/base/MigrationRunner.test.ts b/src/base/MigrationRunner.test.ts new file mode 100644 index 0000000..3ce1ae3 --- /dev/null +++ b/src/base/MigrationRunner.test.ts @@ -0,0 +1,261 @@ +import MigrationRunner from './MigrationRunner'; +import { SchemaMigration } from '../types/SchemaMigration'; +import { JSONSchemaType } from 'ajv'; +import MigrationErrorCodes from '../types/MigrationErrorCodes'; + +describe('MigrationRunner tests', () => { + describe('Constructor tests', () => { + test(`Should be fine`, () => { + expect( + new MigrationRunner([ + { version: 0, schema: {} }, + { version: 1, schema: {}, migration: () => null }, + ]) + ).toBeDefined(); + }); + + test(`Throw ErrorCode=${MigrationErrorCodes.NoMigrations} NoMigrations`, () => { + expect(() => new MigrationRunner([])).toThrow( + `schemaMigrations can't be empty` + ); + }); + + test(`Throw ErrorCode=${MigrationErrorCodes.NoZeroMigration} NoZeroMigration`, () => { + expect(() => new MigrationRunner([{ version: 1, schema: {} }])).toThrow( + 'schemaMigrations should have migration for `version=0`' + ); + }); + + test(`Throw ErrorCode=${MigrationErrorCodes.IncorrectMigrationsOrder} IncorrectMigrationsOrder`, () => { + expect( + () => + new MigrationRunner([ + { version: 0, schema: {} }, + { version: 2, schema: {} }, + ]) + ).toThrow('Each version should go one after the other'); + }); + }); + + describe('Migration tests', () => { + type TypeV0 = { data: number }; + type TypeV1 = { data: number[]; __version: number }; + type TypeV2 = { + data: number[]; + additionalData: string; + __version: number; + }; + const schemaV0: JSONSchemaType = { + type: 'object', + properties: { + data: { type: 'number' }, + }, + required: ['data'], + }; + const schemaV1: JSONSchemaType = { + type: 'object', + properties: { + __version: { type: 'number' }, + data: { type: 'array', items: { type: 'number' } }, + }, + required: ['data', '__version'], + }; + const schemaV2: JSONSchemaType = { + type: 'object', + properties: { + __version: { type: 'number' }, + data: { type: 'array', items: { type: 'number' } }, + additionalData: { type: 'string' }, + }, + required: ['data', '__version', 'additionalData'], + }; + const migrations: SchemaMigration[] = [ + { version: 0, schema: schemaV0 }, + { + version: 1, + schema: schemaV1, + migration(item: TypeV0): TypeV1 { + return { + data: [item.data], + __version: 1, + }; + }, + }, + { + version: 2, + schema: schemaV2, + migration(item: TypeV1): TypeV2 { + return { + data: item.data, + additionalData: 'test', + __version: 2, + }; + }, + }, + ]; + + test('Test migration with 3 iterations', () => { + const dataV0: TypeV0 = { data: 777 }; + const expectedData: TypeV2 = { + data: [777], + additionalData: 'test', + __version: 2, + }; + const mr = new MigrationRunner(migrations); + + const resultData = mr.runMigration(dataV0); + expect(resultData).toStrictEqual(expectedData); + }); + + test('Test continue migration', () => { + const dataV0: TypeV1 = { data: [777], __version: 1 }; + const expectedData: TypeV2 = { + data: [777], + additionalData: 'test', + __version: 2, + }; + const mr = new MigrationRunner(migrations); + + const resultData = mr.runMigration(dataV0); + expect(resultData).toStrictEqual(expectedData); + }); + }); + + describe('Migration errors', () => { + test(`Throw ErrorCode=${MigrationErrorCodes.ValidationFailed} ValidationFailed`, () => { + type TypeV0 = { data: number; text: string; prop: { test: 1 } }; + + const schemaV0: JSONSchemaType = { + type: 'object', + properties: { + data: { type: 'number' }, + text: { type: 'string' }, + prop: { + type: 'object', + properties: { test: { type: 'number' } }, + required: ['test'], + }, + }, + required: ['data', 'text'], + }; + + const migrations: SchemaMigration[] = [{ version: 0, schema: schemaV0 }]; + + const dataV0 = { fakeData: 77, text: 123, prop: { testFail: 1 } }; + + const mr = new MigrationRunner(migrations); + + expect(() => mr.runMigration(dataV0)).toThrow( + [ + 'Migration to version=1. Schema validation error. Found next errors:', + '"/": "must have required property \'data\'"', + '"/text": "must be string"', + '"/prop": "must have required property \'test\'"', + ].join('\n') + ); + }); + + test(`Throw ErrorCode=${MigrationErrorCodes.MigrationNotFound} MigrationNotFound - no migration for version=1`, () => { + type TypeV0 = { data: number }; + + const schemaV0: JSONSchemaType = { + type: 'object', + properties: { + data: { type: 'number' }, + }, + required: ['data'], + }; + + const migrations: SchemaMigration[] = [ + { version: 0, schema: schemaV0 }, + { version: 1, schema: schemaV0 }, + ]; + + const dataV0 = { data: 1 }; + + const mr = new MigrationRunner(migrations); + + expect(() => mr.runMigration(dataV0)).toThrow( + /Migration {\d->\d} not found/ + ); + }); + + test(`Throw ErrorCode=${MigrationErrorCodes.MigrationFailed} MigrationFailed - migration returned undefined`, () => { + type TypeV0 = { data: number }; + type TypeV1 = { data: number }; + + const schemaV0: JSONSchemaType = { + type: 'object', + properties: { + data: { type: 'number' }, + }, + required: ['data'], + }; + const schemaV1: JSONSchemaType = { + type: 'object', + properties: { + data: { type: 'number' }, + }, + required: ['data'], + }; + + const migrations: SchemaMigration[] = [ + { version: 0, schema: schemaV0 }, + { + version: 1, + schema: schemaV1, + migration() { + return undefined; + }, + }, + ]; + + const dataV0 = { data: 1 }; + + const mr = new MigrationRunner(migrations); + + expect(() => mr.runMigration(dataV0)).toThrow( + `migration returned 'undefined'` + ); + }); + + test(`Throw ErrorCode=${MigrationErrorCodes.MigrationFailed} MigrationFailed migration returned 'data' without '__version'`, () => { + type TypeV0 = { data: number }; + type TypeV1 = { data: number }; + + const schemaV0: JSONSchemaType = { + type: 'object', + properties: { + data: { type: 'number' }, + }, + required: ['data'], + }; + const schemaV1: JSONSchemaType = { + type: 'object', + properties: { + data: { type: 'number' }, + }, + required: ['data'], + }; + + const migrations: SchemaMigration[] = [ + { version: 0, schema: schemaV0 }, + { + version: 1, + schema: schemaV1, + migration(item) { + return item; + }, + }, + ]; + + const dataV0 = { data: 1 }; + + const mr = new MigrationRunner(migrations); + + expect(() => mr.runMigration(dataV0)).toThrow( + `migration returned 'data' without '__version'` + ); + }); + }); +}); diff --git a/src/base/MigrationRunner.ts b/src/base/MigrationRunner.ts new file mode 100644 index 0000000..a6ab8b3 --- /dev/null +++ b/src/base/MigrationRunner.ts @@ -0,0 +1,130 @@ +import Ajv, { ValidateFunction, _ } from 'ajv'; +import { SchemaMigration } from '../types/SchemaMigration'; +import { SchemaType } from '../types/SchemaType'; +import { last } from '../helpers/ArrayHelper'; +import MigrationErrorCodes from '../types/MigrationErrorCodes'; + +function migrationAssert( + assertValue: unknown, + error: MigrationErrorCodes | undefined, + message: string +): asserts assertValue { + if (!assertValue) { + throw new Error(`[MigrationRunner] [error=${error ?? -1}] ${message}`); + } +} + +function migrationAssertShowValidationErrors( + assertValue: unknown, + validate: ValidateFunction, + toVersion: number +): asserts assertValue { + const errors = validate.errors + ?.map((e) => `"${e.instancePath ? e.instancePath : '/'}": "${e.message}"`) + ?.join('\n'); + return migrationAssert( + assertValue, + MigrationErrorCodes.ValidationFailed, + `Migration to version=${toVersion}. Schema validation error. Found next errors:\n${errors}}` + ); +} + +export default class MigrationRunner { + private schemaMigrations: SchemaMigration[] = []; + private ajv: Ajv; + + constructor(schemaMigrations: SchemaMigration[]) { + migrationAssert( + schemaMigrations.length, + MigrationErrorCodes.NoMigrations, + `schemaMigrations can't be empty` + ); + + const schemaMigrationsSorted = schemaMigrations.slice(); + schemaMigrationsSorted.sort((a, b) => a.version - b.version); + + migrationAssert( + schemaMigrationsSorted.find((i) => i.version === 0), + MigrationErrorCodes.NoZeroMigration, + 'schemaMigrations should have migration for `version=0`' + ); + + const hasAllVersions = schemaMigrations.every( + (i, idx) => i.version === idx + ); + + migrationAssert( + hasAllVersions, + MigrationErrorCodes.IncorrectMigrationsOrder, + 'Each version should go one after the other' + ); + + this.schemaMigrations = schemaMigrationsSorted; + this.ajv = new Ajv({ allErrors: true }); + } + + runMigration(data: T): TRes { + let newData: T = data; + let fromVersion = newData.__version || 0; + let toVersion = fromVersion !== undefined ? fromVersion + 1 : 1; + + const latestVersion = last(this.schemaMigrations)?.version; + + migrationAssert( + latestVersion !== undefined, + MigrationErrorCodes.NoMigrations, + 'There are no migrations' + ); + + const migration = this.schemaMigrations.find( + (i) => i.version === fromVersion + ); + + migrationAssert( + migration?.schema, + MigrationErrorCodes.NoMigrations, + 'There are no migrations' + ); + + const validate = this.ajv.compile(migration.schema); + const validateResult = validate(newData); + migrationAssertShowValidationErrors(validateResult, validate, toVersion); + + while (true) { + if (toVersion > latestVersion) { + return newData as TRes; + } + + const migration = this.schemaMigrations.find( + (m) => m.version === toVersion + ); + + migrationAssert( + migration?.migration, + MigrationErrorCodes.MigrationNotFound, + `Migration {${fromVersion}->${toVersion}} not found` + ); + + const nextData: T = migration.migration(newData); + + migrationAssert( + nextData, + MigrationErrorCodes.MigrationFailed, + `After run migration {${fromVersion}->${toVersion}}, migration returned 'undefined'` + ); + + migrationAssert( + nextData.__version !== undefined, + MigrationErrorCodes.MigrationFailed, + `After run migration {${fromVersion}->${toVersion}}, migration returned 'data' without '__version'` + ); + + const validate = this.ajv.compile(migration.schema); + const validateResult = validate(nextData); + migrationAssertShowValidationErrors(validateResult, validate, toVersion); + + newData = nextData; + toVersion = nextData.__version + 1; + } + } +} diff --git a/src/base/repositories/AbstractFileRepository.ts b/src/base/repositories/AbstractFileRepository.ts index b2eefc1..4e4c8c4 100644 --- a/src/base/repositories/AbstractFileRepository.ts +++ b/src/base/repositories/AbstractFileRepository.ts @@ -5,7 +5,9 @@ const path = require('path'); import { ipcRenderer } from 'electron'; import FsHelper from '../../helpers/FsHelper'; -import PromiseQueue from '../../helpers/PromiseQueueHellper'; +import PromiseQueue from '../../helpers/PromiseQueueHelper'; +import { SchemaMigration } from '../../types/SchemaMigration'; +import MigrationRunner from '../MigrationRunner'; const APP_DIR = process.env.NODE_ENV === 'development' @@ -14,18 +16,24 @@ const APP_DIR = let _appDataPath: string = ''; -export default abstract class AbstractFileRepository { +export default abstract class AbstractFileRepository { dirWithProfileData: string = 'profile1'; fileName: string = 'defaultFileName.json'; saveInRoot: boolean = false; + schemaMigrations: SchemaMigration[] = []; - writeFileQueue = new PromiseQueue(); + private writeFileQueue = new PromiseQueue(); + private migrationRunner: MigrationRunner; private get logPrefix() { const filePath = !this.saveInRoot ? this.dirWithProfileData : ''; return `FileRepository [${filePath}/${this.fileName}]:`; } + constructor() { + this.migrationRunner = new MigrationRunner(this.schemaMigrations); + } + static get appDataFolder() { if (_appDataPath) { return _appDataPath; @@ -55,7 +63,8 @@ export default abstract class AbstractFileRepository { if (fs.existsSync(this.filePath)) { const data = fs.readFileSync(this.filePath, { encoding: 'utf-8' }); // TODO handle parse error. Backup file with issues and return defaultValue - return JSON.parse(data); + const parsedData = JSON.parse(data); + return this.migrationRunner.runMigration(parsedData); } return defaultValue; } diff --git a/src/helpers/PromiseQueueHellper.ts b/src/helpers/PromiseQueueHelper.ts similarity index 100% rename from src/helpers/PromiseQueueHellper.ts rename to src/helpers/PromiseQueueHelper.ts diff --git a/src/modules/projects/ProjectFactory.ts b/src/modules/projects/ProjectFactory.ts index 672c40e..35c1332 100644 --- a/src/modules/projects/ProjectFactory.ts +++ b/src/modules/projects/ProjectFactory.ts @@ -2,12 +2,12 @@ import AbstractFactory from '../../base/AbstractFactory'; import ProjectModel, { DEFAULT_PROJECT_ID, DEFAULT_PROJECTS, - IJsonProjectItem, } from './models/ProjectModel'; import { Features } from '../../config'; +import { ProjectTypeV1 } from './types/ProjectTypeV1'; export default class ProjectFactory extends AbstractFactory { - createProjects(projectItems: IJsonProjectItem[]): ProjectModel[] { + createProjects(projectItems: ProjectTypeV1[]): ProjectModel[] { if (Features.myDay) { const hasMyDay = projectItems.find( (p) => p.key === DEFAULT_PROJECT_ID.MyDay diff --git a/src/modules/projects/ProjectRepository.ts b/src/modules/projects/ProjectRepository.ts index 9642637..1246b11 100644 --- a/src/modules/projects/ProjectRepository.ts +++ b/src/modules/projects/ProjectRepository.ts @@ -1,8 +1,10 @@ import AbstractFileRepository from '../../base/repositories/AbstractFileRepository'; -import { IJsonProjectItem } from './models/ProjectModel'; +import { schemaMigrations } from './migrations'; +import { ProjectDataV1 } from './types'; export default class ProjectRepository extends AbstractFileRepository< - IJsonProjectItem[] + ProjectDataV1 > { fileName = 'projects.json'; + schemaMigrations = schemaMigrations; } diff --git a/src/modules/projects/ProjectService.ts b/src/modules/projects/ProjectService.ts index 650916d..10ab4b8 100644 --- a/src/modules/projects/ProjectService.ts +++ b/src/modules/projects/ProjectService.ts @@ -1,15 +1,14 @@ -import ProjectModel, { - DEFAULT_PROJECTS, - IJsonProjectItem, -} from './models/ProjectModel'; +import { toJS } from 'mobx'; +import ProjectModel, { IJsonProjectItem } from './models/ProjectModel'; import ProjectFactory from './ProjectFactory'; import ProjectRepository from './ProjectRepository'; import AbstractServiceWithProfile from '../../base/AbstractServiceWithProfile'; import TreeModelHelper from '../../helpers/TreeModelHelper'; -import { toJS } from 'mobx'; +import DEFAULT_PROJECTS from './models/DefaultProjects'; +import { ProjectDataV0, ProjectDataV1 } from './types'; export default class ProjectService extends AbstractServiceWithProfile< - ProjectModel[] + ProjectDataV0 > { private factory = new ProjectFactory(); protected repository = new ProjectRepository(); @@ -17,7 +16,7 @@ export default class ProjectService extends AbstractServiceWithProfile< getAll(): ProjectModel[] { const data = this.repository.restore(DEFAULT_PROJECTS); ProjectService.fillParent(data); - return this.factory.createProjects(data); + return this.factory.createProjects(data.data); } save(data: ProjectModel[]): void { @@ -26,8 +25,8 @@ export default class ProjectService extends AbstractServiceWithProfile< this.repository.save(copyData); } - private static fillParent(data: IJsonProjectItem[]) { - TreeModelHelper.fillParent(data); + private static fillParent(data: ProjectDataV1) { + TreeModelHelper.fillParent(data.data); } private static clearParent(data: ProjectModel[]) { diff --git a/src/modules/projects/ProjectStore.ts b/src/modules/projects/ProjectStore.ts index 97d47ee..15ade05 100644 --- a/src/modules/projects/ProjectStore.ts +++ b/src/modules/projects/ProjectStore.ts @@ -30,7 +30,10 @@ export default class ProjectStore { set(projects: ProjectModel[]) { this.projects = projects; - this.projectService.save(this.projects); + this.projectService.save({ + __version: 1, + data: this.projects, + }); } setEditableProject(project?: ProjectModel) { diff --git a/src/modules/projects/ProjectTypes.ts b/src/modules/projects/ProjectTypes.ts new file mode 100644 index 0000000..0c435a5 --- /dev/null +++ b/src/modules/projects/ProjectTypes.ts @@ -0,0 +1,4 @@ +export enum DEFAULT_PROJECT_ID { + MyDay = '0', + Inbox = '1', +} diff --git a/src/modules/projects/migrations/MigrationV1.ts b/src/modules/projects/migrations/MigrationV1.ts new file mode 100644 index 0000000..afdaab2 --- /dev/null +++ b/src/modules/projects/migrations/MigrationV1.ts @@ -0,0 +1,8 @@ +import { ProjectDataV0, ProjectDataV1 } from '../types'; + +export default function migration(data: ProjectDataV0): ProjectDataV1 { + return { + __version: 1, + data, + }; +} diff --git a/src/modules/projects/migrations/index.ts b/src/modules/projects/migrations/index.ts new file mode 100644 index 0000000..5d1d24b --- /dev/null +++ b/src/modules/projects/migrations/index.ts @@ -0,0 +1,8 @@ +import migrationV1 from './MigrationV1'; +import { ProjectSchemaV0, ProjectDataSchemaV1 } from '../schemas'; +import { SchemaMigration } from '../../../types/SchemaMigration'; + +export const schemaMigrations: SchemaMigration[] = [ + { version: 0, schema: ProjectSchemaV0 }, + { version: 1, schema: ProjectDataSchemaV1, migration: migrationV1 }, +]; diff --git a/src/modules/projects/models/DefaultProjects.ts b/src/modules/projects/models/DefaultProjects.ts new file mode 100644 index 0000000..198d4a6 --- /dev/null +++ b/src/modules/projects/models/DefaultProjects.ts @@ -0,0 +1,26 @@ +import * as colors from '@ant-design/colors'; +import { ProjectDataV1 } from '../types/ProjectTypeV1'; +import { DEFAULT_PROJECT_ID } from '../ProjectTypes'; + +const DEFAULT_PROJECTS: ProjectDataV1 = { + __version: 1, + data: [ + // { + // key: DEFAULT_PROJECT_ID.MyDay, + // title: 'My Day', + // color: colors.yellow.primary || '', + // deletable: false, + // expanded: false, + // }, + { + key: DEFAULT_PROJECT_ID.Inbox, + title: 'Inbox', + color: colors.blue.primary || '', + deletable: false, + expanded: false, + parent: undefined, + }, + ], +}; + +export default DEFAULT_PROJECTS; diff --git a/src/modules/projects/schemas/ProjectSchemaV0.ts b/src/modules/projects/schemas/ProjectSchemaV0.ts new file mode 100644 index 0000000..8f224c9 --- /dev/null +++ b/src/modules/projects/schemas/ProjectSchemaV0.ts @@ -0,0 +1,29 @@ +import { JSONSchemaType } from 'ajv'; +import { ProjectTypeV0, ProjectDataV0 } from '../types'; + +export const ProjectSchemaV0: JSONSchemaType = { + type: 'object', + properties: { + key: { type: 'string' }, + title: { type: 'string' }, + color: { type: 'string' }, + expanded: { type: 'boolean', default: false, nullable: true }, + deletable: { type: 'boolean', default: true, nullable: true }, + children: { + type: 'array', + items: { + type: 'object', + $ref: '#', + required: ['key', 'title', 'color'], + }, + nullable: true, + }, + parent: { type: 'object', $ref: '#', nullable: true }, + }, + required: [], +}; + +export const ProjectDataSchemaV0: JSONSchemaType = { + type: 'array', + items: ProjectSchemaV0, +}; diff --git a/src/modules/projects/schemas/ProjectSchemaV1.ts b/src/modules/projects/schemas/ProjectSchemaV1.ts new file mode 100644 index 0000000..9dca172 --- /dev/null +++ b/src/modules/projects/schemas/ProjectSchemaV1.ts @@ -0,0 +1,36 @@ +import { JSONSchemaType } from 'ajv'; +import { ProjectTypeV1, ProjectDataV1 } from '../types'; + +export const ProjectSchemaV1: JSONSchemaType = { + type: 'object', + properties: { + key: { type: 'string' }, + title: { type: 'string' }, + color: { type: 'string' }, + expanded: { type: 'boolean', default: false, nullable: true }, + deletable: { type: 'boolean', default: true, nullable: true }, + children: { + type: 'array', + items: { + type: 'object', + $ref: '#', + required: ['key', 'title', 'color'], + }, + nullable: true, + }, + parent: { type: 'object', $ref: '#', nullable: true }, + }, + required: ['key', 'title', 'color'], +}; + +export const ProjectDataSchemaV1: JSONSchemaType = { + type: 'object', + properties: { + __version: { type: 'number' }, + data: { + type: 'array', + items: ProjectSchemaV1, + }, + }, + required: ['__version', 'data'], +}; diff --git a/src/modules/projects/schemas/index.ts b/src/modules/projects/schemas/index.ts new file mode 100644 index 0000000..b04f45a --- /dev/null +++ b/src/modules/projects/schemas/index.ts @@ -0,0 +1,2 @@ +export { ProjectSchemaV0 } from './ProjectSchemaV0'; +export { ProjectSchemaV1, ProjectDataSchemaV1 } from './ProjectSchemaV1'; diff --git a/src/modules/projects/types/ProjectTypeV0.ts b/src/modules/projects/types/ProjectTypeV0.ts new file mode 100644 index 0000000..e5a33c9 --- /dev/null +++ b/src/modules/projects/types/ProjectTypeV0.ts @@ -0,0 +1,10 @@ +import { ITreeItemWithParent } from '../../../types/ITreeItem'; + +export interface ProjectTypeV0 extends ITreeItemWithParent { + color: string; + expanded: boolean; + deletable: boolean; + children?: ProjectTypeV0[]; +} + +export type ProjectDataV0 = ProjectTypeV0[]; diff --git a/src/modules/projects/types/ProjectTypeV1.ts b/src/modules/projects/types/ProjectTypeV1.ts new file mode 100644 index 0000000..5c3391c --- /dev/null +++ b/src/modules/projects/types/ProjectTypeV1.ts @@ -0,0 +1,13 @@ +import { ITreeItemWithParent } from '../../../types/ITreeItem'; +import { ModelWithVersion } from '../../../types/ModelWithVersion'; + +export interface ProjectTypeV1 extends ITreeItemWithParent { + color: string; + expanded: boolean; + deletable: boolean; + children?: ProjectTypeV1[]; +} + +export interface ProjectDataV1 extends ModelWithVersion { + data: ProjectTypeV1[]; +} diff --git a/src/modules/projects/types/index.ts b/src/modules/projects/types/index.ts new file mode 100644 index 0000000..9ee97a9 --- /dev/null +++ b/src/modules/projects/types/index.ts @@ -0,0 +1,2 @@ +export * from './ProjectTypeV0'; +export * from './ProjectTypeV1'; diff --git a/src/modules/settings/SettingsRepository.ts b/src/modules/settings/SettingsRepository.ts index 2adbed6..5e0836b 100644 --- a/src/modules/settings/SettingsRepository.ts +++ b/src/modules/settings/SettingsRepository.ts @@ -1,6 +1,8 @@ import AbstractFileRepository from '../../base/repositories/AbstractFileRepository'; +import { schemaMigrations } from './migrations'; export default class SettingsRepository extends AbstractFileRepository { saveInRoot = true; fileName = 'settings.json'; + schemaMigrations = schemaMigrations; } diff --git a/src/modules/settings/SettingsService.ts b/src/modules/settings/SettingsService.ts index ad9fbee..1f419f6 100644 --- a/src/modules/settings/SettingsService.ts +++ b/src/modules/settings/SettingsService.ts @@ -1,5 +1,6 @@ import IService from '../../base/IService'; -import SettingsModel, { DEFAULT_SETTINGS } from './models/SettingsModel'; +import { DEFAULT_SETTINGS } from './consts'; +import SettingsModel from './models/SettingsModel'; import SettingsFactory from './SettingsFactory'; import SettingsRepository from './SettingsRepository'; diff --git a/src/modules/settings/SettingsStore.ts b/src/modules/settings/SettingsStore.ts index f4172b6..f3f04e0 100644 --- a/src/modules/settings/SettingsStore.ts +++ b/src/modules/settings/SettingsStore.ts @@ -1,6 +1,6 @@ import { makeAutoObservable } from 'mobx'; -import SettingsModel, { DEFAULT_SETTINGS } from './models/SettingsModel'; +import SettingsModel from './models/SettingsModel'; import SettingsService from './SettingsService'; import { RootStore } from '../RootStore'; import { ISettings } from './models/ISettings'; diff --git a/src/modules/settings/SettingsValidator.ts b/src/modules/settings/SettingsValidator.ts new file mode 100644 index 0000000..8b1e14b --- /dev/null +++ b/src/modules/settings/SettingsValidator.ts @@ -0,0 +1,5 @@ +import Ajv from 'ajv'; +import ProjectSchemaV1 from './schemas/SettingsSchemaV1'; + +const ajv = new Ajv({ allErrors: true }); +export const validate = ajv.compile(ProjectSchemaV1); diff --git a/src/modules/settings/consts.ts b/src/modules/settings/consts.ts new file mode 100644 index 0000000..8aace43 --- /dev/null +++ b/src/modules/settings/consts.ts @@ -0,0 +1,10 @@ +import { SettingsV1 } from './types/SettingsTypeV1'; + +export const DEFAULT_SETTINGS: SettingsV1 = { + __version: 1, + currentProfile: 'profile1', + profiles: ['profile1'], + numberOfWorkingHours: 8 * 60 * 60 * 1000, + isFirstLoad: true, + showNotifications: true, +}; diff --git a/src/modules/settings/migrations/MigrationV1.ts b/src/modules/settings/migrations/MigrationV1.ts new file mode 100644 index 0000000..cc03c11 --- /dev/null +++ b/src/modules/settings/migrations/MigrationV1.ts @@ -0,0 +1,7 @@ +import { SettingsV0, SettingsV1 } from '../types'; + +export default function migration(data: SettingsV0): SettingsV1 { + return Object.assign({}, data, { + __version: 1, + }); +} diff --git a/src/modules/settings/migrations/index.ts b/src/modules/settings/migrations/index.ts new file mode 100644 index 0000000..4bb6da6 --- /dev/null +++ b/src/modules/settings/migrations/index.ts @@ -0,0 +1,8 @@ +import migrationV1 from './MigrationV1'; +import { SettingsSchemaV0, SettingsSchemaV1 } from '../schemas'; +import { SchemaMigration } from '../../../types/SchemaMigration'; + +export const schemaMigrations: SchemaMigration[] = [ + { version: 0, schema: SettingsSchemaV0 }, + { version: 1, schema: SettingsSchemaV1, migration: migrationV1 }, +]; diff --git a/src/modules/settings/models/SettingsModel.ts b/src/modules/settings/models/SettingsModel.ts index 1917030..21a870e 100644 --- a/src/modules/settings/models/SettingsModel.ts +++ b/src/modules/settings/models/SettingsModel.ts @@ -1,22 +1,18 @@ -import AbstractModel from '../../../base/AbstractModel'; import { makeObservable, observable } from 'mobx'; -export const DEFAULT_SETTINGS = { - currentProfile: 'profile1', - profiles: ['profile1'], - numberOfWorkingHours: 8 * 60 * 60 * 1000, - isFirstLoad: true, - showNotifications: true, -}; +import AbstractModel from '../../../base/AbstractModel'; +import { SettingsV1 } from '../types/SettingsTypeV1'; +import { DEFAULT_SETTINGS } from '../consts'; -export default class SettingsModel extends AbstractModel { +export default class SettingsModel extends AbstractModel implements SettingsV1 { + readonly __version: number = 0; currentProfile: string = DEFAULT_SETTINGS.currentProfile; profiles: string[] = DEFAULT_SETTINGS.profiles; numberOfWorkingHours: number = DEFAULT_SETTINGS.numberOfWorkingHours; isFirstLoad: boolean = DEFAULT_SETTINGS.isFirstLoad; showNotifications: boolean = DEFAULT_SETTINGS.showNotifications; - constructor(data: any) { + constructor(data: SettingsV1) { super(); this.load(data); makeObservable(this, { diff --git a/src/modules/settings/schemas/SettingsSchemaV0.ts b/src/modules/settings/schemas/SettingsSchemaV0.ts new file mode 100644 index 0000000..cc9107c --- /dev/null +++ b/src/modules/settings/schemas/SettingsSchemaV0.ts @@ -0,0 +1,23 @@ +import { JSONSchemaType } from 'ajv'; +import { SettingsV0 } from '../types/SettingsTypeV0'; + +export const SettingsSchemaV0: JSONSchemaType = { + type: 'object', + properties: { + currentProfile: { type: 'string' }, + profiles: { + type: 'array', + items: { type: 'string' }, + }, + numberOfWorkingHours: { type: 'number' }, + isFirstLoad: { type: 'boolean' }, + showNotifications: { type: 'boolean' }, + }, + required: [ + 'currentProfile', + 'profiles', + 'numberOfWorkingHours', + 'isFirstLoad', + 'showNotifications', + ], +}; diff --git a/src/modules/settings/schemas/SettingsSchemaV1.ts b/src/modules/settings/schemas/SettingsSchemaV1.ts new file mode 100644 index 0000000..468f0b3 --- /dev/null +++ b/src/modules/settings/schemas/SettingsSchemaV1.ts @@ -0,0 +1,25 @@ +import { JSONSchemaType } from 'ajv'; +import { SettingsV1 } from '../types/SettingsTypeV1'; + +export const SettingsSchemaV1: JSONSchemaType = { + type: 'object', + properties: { + __version: { type: 'number' }, + currentProfile: { type: 'string' }, + profiles: { + type: 'array', + items: { type: 'string' }, + }, + numberOfWorkingHours: { type: 'number' }, + isFirstLoad: { type: 'boolean' }, + showNotifications: { type: 'boolean' }, + }, + required: [ + '__version', + 'currentProfile', + 'profiles', + 'numberOfWorkingHours', + 'isFirstLoad', + 'showNotifications', + ], +}; diff --git a/src/modules/settings/schemas/index.ts b/src/modules/settings/schemas/index.ts new file mode 100644 index 0000000..031e46f --- /dev/null +++ b/src/modules/settings/schemas/index.ts @@ -0,0 +1,2 @@ +export * from './SettingsSchemaV0'; +export * from './SettingsSchemaV1'; diff --git a/src/modules/settings/types/SettingsTypeV0.ts b/src/modules/settings/types/SettingsTypeV0.ts new file mode 100644 index 0000000..162ddfc --- /dev/null +++ b/src/modules/settings/types/SettingsTypeV0.ts @@ -0,0 +1,7 @@ +export interface SettingsV0 { + currentProfile: string; + profiles: string[]; + numberOfWorkingHours: number; + isFirstLoad: boolean; + showNotifications: boolean; +} diff --git a/src/modules/settings/types/SettingsTypeV1.ts b/src/modules/settings/types/SettingsTypeV1.ts new file mode 100644 index 0000000..36d0011 --- /dev/null +++ b/src/modules/settings/types/SettingsTypeV1.ts @@ -0,0 +1,9 @@ +import { ModelWithVersion } from '../../../types/ModelWithVersion'; + +export interface SettingsV1 extends ModelWithVersion { + currentProfile: string; + profiles: string[]; + numberOfWorkingHours: number; + isFirstLoad: boolean; + showNotifications: boolean; +} diff --git a/src/modules/settings/types/index.ts b/src/modules/settings/types/index.ts new file mode 100644 index 0000000..0a8dbd0 --- /dev/null +++ b/src/modules/settings/types/index.ts @@ -0,0 +1,2 @@ +export * from './SettingsTypeV0'; +export * from './SettingsTypeV1'; diff --git a/src/modules/tasks/TaskRepository.ts b/src/modules/tasks/TaskRepository.ts index 85653c6..0bd4f2c 100644 --- a/src/modules/tasks/TaskRepository.ts +++ b/src/modules/tasks/TaskRepository.ts @@ -1,8 +1,10 @@ import AbstractFileRepository from '../../base/repositories/AbstractFileRepository'; import { TasksByProject } from './models/TasksByProject'; +import { schemaMigrations } from './migrations'; export default class TaskRepository extends AbstractFileRepository< TasksByProject > { fileName = 'tasks.json'; + schemaMigrations = schemaMigrations; } diff --git a/src/modules/tasks/migrations/MigrationV1.ts b/src/modules/tasks/migrations/MigrationV1.ts new file mode 100644 index 0000000..2196848 --- /dev/null +++ b/src/modules/tasks/migrations/MigrationV1.ts @@ -0,0 +1,8 @@ +import { TaskDataV1, TaskDataV0 } from '../types'; + +export default function migration(data: TaskDataV0): TaskDataV1 { + return { + data, + __version: 1, + }; +} diff --git a/src/modules/tasks/migrations/index.ts b/src/modules/tasks/migrations/index.ts new file mode 100644 index 0000000..f71849f --- /dev/null +++ b/src/modules/tasks/migrations/index.ts @@ -0,0 +1,8 @@ +import { SchemaMigration } from '../../../types/SchemaMigration'; +import { TaskSchemaV0, TaskSchemaV1 } from '../schemas'; +import migrationV1 from './MigrationV1'; + +export const schemaMigrations: SchemaMigration[] = [ + { version: 0, schema: TaskSchemaV0 }, + { version: 1, schema: TaskSchemaV1, migration: migrationV1 }, +]; diff --git a/src/modules/tasks/models/TaskInMyDay.ts b/src/modules/tasks/models/TaskInMyDay.ts index 4a346d1..072a5ee 100644 --- a/src/modules/tasks/models/TaskInMyDay.ts +++ b/src/modules/tasks/models/TaskInMyDay.ts @@ -12,8 +12,8 @@ export class TaskInMyDay extends TaskModel { } export const taskModelProxyHandler: ProxyHandler = { - get(target: TaskInMyDay, prop: string | symbol): any { - return target?.[prop as keyof TaskInMyDay]; + get(target: TaskInMyDay, prop: keyof TaskInMyDay): any { + return target?.[prop]; }, set(target: TaskInMyDay, prop: string | symbol, value: any): boolean { if (prop === 'duration') { diff --git a/src/modules/tasks/schemas/TaskSchemaV0.ts b/src/modules/tasks/schemas/TaskSchemaV0.ts new file mode 100644 index 0000000..406b0fc --- /dev/null +++ b/src/modules/tasks/schemas/TaskSchemaV0.ts @@ -0,0 +1,67 @@ +import { JSONSchemaType } from 'ajv'; +import { TaskDataV0, TaskTypeV0, TimeRangeTypeV0 } from '../types'; + +export const TimeRangeSchemaV0: JSONSchemaType = { + type: 'object', + properties: { + start: { type: 'string' }, + end: { type: 'string', nullable: true }, + description: { type: 'string', nullable: true }, + }, + required: ['start'], +}; + +export const TaskSchemaV0: JSONSchemaType = { + type: 'object', + properties: { + key: { type: 'string' }, + title: { type: 'string' }, + children: { + type: 'array', + items: { + type: 'object', + $ref: '#', + required: ['key', 'title'], + }, + nullable: true, + }, + projectId: { type: 'string' }, + checked: { type: 'boolean' }, + active: { type: 'boolean' }, + expanded: { type: 'boolean' }, + inMyDay: { type: 'string', nullable: true }, + time: { + type: 'array', + items: TimeRangeSchemaV0, + }, + datesInProgress: { + type: 'array', + items: { type: 'string' }, + }, + details: { type: 'string' }, + withoutActions: { type: 'boolean' }, + }, + required: [ + 'key', + 'title', + 'projectId', + 'checked', + 'active', + 'expanded', + 'time', + 'datesInProgress', + 'details', + 'withoutActions', + ], +}; + +export const TaskDataSchemaV0: JSONSchemaType = { + type: 'object', + patternProperties: { + '.*': { + type: 'array', + items: TaskSchemaV0, + }, + }, + required: [], +}; diff --git a/src/modules/tasks/schemas/TaskSchemaV1.ts b/src/modules/tasks/schemas/TaskSchemaV1.ts new file mode 100644 index 0000000..c93ed04 --- /dev/null +++ b/src/modules/tasks/schemas/TaskSchemaV1.ts @@ -0,0 +1,75 @@ +import { JSONSchemaType } from 'ajv'; +import { TaskTypeV1, TimeRangeTypeV1, TaskDataV1 } from '../types'; +import { TaskSchemaV0 } from './TaskSchemaV0'; + +export const TimeRangeSchemaV1: JSONSchemaType = { + type: 'object', + properties: { + start: { type: 'string' }, + end: { type: 'string', nullable: true }, + description: { type: 'string', nullable: true }, + }, + required: ['start'], +}; + +export const TaskSchemaV1: JSONSchemaType = { + type: 'object', + properties: { + key: { type: 'string' }, + title: { type: 'string' }, + children: { + type: 'array', + items: { + type: 'object', + $ref: '#', + required: ['key', 'title'], + }, + nullable: true, + }, + projectId: { type: 'string' }, + checked: { type: 'boolean' }, + active: { type: 'boolean' }, + expanded: { type: 'boolean' }, + inMyDay: { type: 'string', nullable: true }, + time: { + type: 'array', + items: TimeRangeSchemaV1, + }, + datesInProgress: { + type: 'array', + items: { type: 'string' }, + }, + details: { type: 'string' }, + withoutActions: { type: 'boolean' }, + }, + required: [ + 'key', + 'title', + 'projectId', + 'checked', + 'active', + 'expanded', + 'time', + 'datesInProgress', + 'details', + 'withoutActions', + ], +}; + +export const TaskDataSchemaV1: JSONSchemaType = { + type: 'object', + properties: { + __version: { type: 'number' }, + data: { + type: 'object', + patternProperties: { + '.*': { + type: 'array', + items: TaskSchemaV0, + }, + }, + required: [], + }, + }, + required: ['data', '__version'], +}; diff --git a/src/modules/tasks/schemas/index.ts b/src/modules/tasks/schemas/index.ts new file mode 100644 index 0000000..d3e5966 --- /dev/null +++ b/src/modules/tasks/schemas/index.ts @@ -0,0 +1,6 @@ +export { TimeRangeSchemaV0, TaskSchemaV0 } from './TaskSchemaV0'; +export { + TaskDataSchemaV1, + TaskSchemaV1, + TimeRangeSchemaV1, +} from './TaskSchemaV1'; diff --git a/src/modules/tasks/types/TaskTypeV0.ts b/src/modules/tasks/types/TaskTypeV0.ts new file mode 100644 index 0000000..a5aece5 --- /dev/null +++ b/src/modules/tasks/types/TaskTypeV0.ts @@ -0,0 +1,23 @@ +export interface TimeRangeTypeV0 { + start: string; + end?: string; + description?: string; +} + +export interface TaskTypeV0 { + key: string; + title: string; + children: TaskTypeV0[] | undefined; + // parent: TaskTypeV0 | undefined; + projectId: string; + checked: boolean; + active: boolean; + expanded: boolean; + inMyDay: string | undefined; + time: TimeRangeTypeV0[]; + datesInProgress: string[]; + details: string; + withoutActions: boolean; +} + +export type TaskDataV0 = Record; diff --git a/src/modules/tasks/types/TaskTypeV1.ts b/src/modules/tasks/types/TaskTypeV1.ts new file mode 100644 index 0000000..147f0e4 --- /dev/null +++ b/src/modules/tasks/types/TaskTypeV1.ts @@ -0,0 +1,27 @@ +import { ModelWithVersion } from '../../../types/ModelWithVersion'; + +export interface TimeRangeTypeV1 { + start: string; + end?: string; + description?: string; +} + +export interface TaskTypeV1 { + key: string; + title: string; + children: TaskTypeV1[] | undefined; + // parent: TaskTypeV1 | undefined; + projectId: string; + checked: boolean; + active: boolean; + expanded: boolean; + inMyDay: string | undefined; + time: TimeRangeTypeV1[]; + datesInProgress: string[]; + details: string; + withoutActions: boolean; +} + +export interface TaskDataV1 extends ModelWithVersion { + data: Record; +} diff --git a/src/modules/tasks/types/index.ts b/src/modules/tasks/types/index.ts new file mode 100644 index 0000000..31751c1 --- /dev/null +++ b/src/modules/tasks/types/index.ts @@ -0,0 +1,2 @@ +export * from './TaskTypeV0'; +export * from './TaskTypeV1'; diff --git a/src/types/IDragInfo.ts b/src/types/IDragInfo.ts index ba279b5..b4aa85b 100644 --- a/src/types/IDragInfo.ts +++ b/src/types/IDragInfo.ts @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React from 'react'; import { EventDataNode, Key } from 'rc-tree/lib/interface'; export interface IDragInfo { diff --git a/src/types/MigrationErrorCodes.ts b/src/types/MigrationErrorCodes.ts new file mode 100644 index 0000000..f53b322 --- /dev/null +++ b/src/types/MigrationErrorCodes.ts @@ -0,0 +1,10 @@ +enum MigrationErrorCodes { + NoMigrations = 0, + NoZeroMigration = 1, + IncorrectMigrationsOrder = 2, + ValidationFailed = 3, + MigrationNotFound = 4, + MigrationFailed = 5, +} + +export default MigrationErrorCodes; diff --git a/src/types/ModelWithVersion.ts b/src/types/ModelWithVersion.ts new file mode 100644 index 0000000..85ee3d0 --- /dev/null +++ b/src/types/ModelWithVersion.ts @@ -0,0 +1,3 @@ +export interface ModelWithVersion { + readonly __version: number; +} diff --git a/src/types/SchemaMigration.ts b/src/types/SchemaMigration.ts new file mode 100644 index 0000000..0c516be --- /dev/null +++ b/src/types/SchemaMigration.ts @@ -0,0 +1,5 @@ +export type SchemaMigration = { + version: number; + schema: any; + migration?: (data: any) => any; +}; diff --git a/src/types/SchemaType.ts b/src/types/SchemaType.ts new file mode 100644 index 0000000..d9bf01a --- /dev/null +++ b/src/types/SchemaType.ts @@ -0,0 +1,3 @@ +export type SchemaType = { + __version?: number; +} & Record; diff --git a/yarn.lock b/yarn.lock index 6205b4a..e28df31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2338,6 +2338,16 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.0, ajv@^6.12.3, ajv@^6.12.4, ajv json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.8.2: + version "8.8.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.8.2.tgz#01b4fef2007a28bf75f0b7fc009f62679de4abbb" + integrity sha512-x9VuX+R/jcFj1DHo/fCp99esgGDWiHENrKxaCENuCxpoMCmAt/COCGVDwA7kleEpEzJjDnvh3yGoOuLu0Dtllw== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + alphanum-sort@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" @@ -7585,6 +7595,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-schema@0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" @@ -10512,6 +10527,11 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + require-main-filename@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"