From 8914df9ff29924f27062fab7aa07b12d40977774 Mon Sep 17 00:00:00 2001 From: yadro Date: Sun, 26 Dec 2021 21:29:46 +0300 Subject: [PATCH 1/6] WIP migration --- .vscode/settings.json | 2 + docs/Migrations.md | 19 ++++ package.json | 1 + src/base/MigrationRunner.test.ts | 100 ++++++++++++++++++ src/base/MigrationRunner.ts | 92 ++++++++++++++++ .../repositories/AbstractFileRepository.ts | 8 +- ...eQueueHellper.ts => PromiseQueueHelper.ts} | 0 src/modules/projects/ProjectValidator.ts | 5 + .../projects/migrations/MigrationV1.ts | 0 .../projects/schemas/ProjectSchemaV0.ts | 26 +++++ src/modules/projects/types/ProjectModelV0.ts | 6 ++ src/modules/settings/SettingsRepository.ts | 2 + src/modules/settings/SettingsService.ts | 3 +- src/modules/settings/SettingsStore.ts | 2 +- src/modules/settings/SettingsValidator.ts | 5 + src/modules/settings/consts.ts | 10 ++ .../settings/migrations/MigrationV1.ts | 8 ++ src/modules/settings/migrations/index.ts | 9 ++ src/modules/settings/models/SettingsModel.ts | 16 ++- .../settings/schemas/SettingsSchemaV1.ts | 27 +++++ src/modules/settings/types/SettingsV0.ts | 7 ++ src/modules/settings/types/SettingsV1.ts | 9 ++ src/types/ModelWithVersion.ts | 3 + src/types/SchemaMigration.ts | 5 + src/types/SchemaType.ts | 3 + yarn.lock | 20 ++++ 26 files changed, 373 insertions(+), 15 deletions(-) create mode 100644 docs/Migrations.md create mode 100644 src/base/MigrationRunner.test.ts create mode 100644 src/base/MigrationRunner.ts rename src/helpers/{PromiseQueueHellper.ts => PromiseQueueHelper.ts} (100%) create mode 100644 src/modules/projects/ProjectValidator.ts create mode 100644 src/modules/projects/migrations/MigrationV1.ts create mode 100644 src/modules/projects/schemas/ProjectSchemaV0.ts create mode 100644 src/modules/projects/types/ProjectModelV0.ts create mode 100644 src/modules/settings/SettingsValidator.ts create mode 100644 src/modules/settings/consts.ts create mode 100644 src/modules/settings/migrations/MigrationV1.ts create mode 100644 src/modules/settings/migrations/index.ts create mode 100644 src/modules/settings/schemas/SettingsSchemaV1.ts create mode 100644 src/modules/settings/types/SettingsV0.ts create mode 100644 src/modules/settings/types/SettingsV1.ts create mode 100644 src/types/ModelWithVersion.ts create mode 100644 src/types/SchemaMigration.ts create mode 100644 src/types/SchemaType.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 2bfea9d..545b84a 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, 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/MigrationRunner.test.ts b/src/base/MigrationRunner.test.ts new file mode 100644 index 0000000..e4bf2a6 --- /dev/null +++ b/src/base/MigrationRunner.test.ts @@ -0,0 +1,100 @@ +import MigrationRunner from './MigrationRunner'; +import { SchemaMigration } from '../types/SchemaMigration'; +import { JSONSchemaType } from 'ajv'; + +describe('MigrationRunner tests', () => { + describe('constructor tests', () => { + test('throw error when schemaMigrations is empty', () => { + expect(() => new MigrationRunner([])).toThrow( + 'schemaMigrations can`t be empty' + ); + }); + + test('throw error when there is no migration `version=0`', () => { + expect(() => new MigrationRunner([{ version: 1, schema: {} }])).toThrow( + 'schemaMigrations should have migration for `version=0`' + ); + }); + + test('throw error when versions doesn`t go one after the other', () => { + expect( + () => + new MigrationRunner([ + { version: 0, schema: {} }, + { version: 2, schema: {} }, + ]) + ).toThrow('Each version should go one after the other'); + }); + }); + + describe('migration tests', () => { + test('Test migration', () => { + 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, + }; + }, + }, + ]; + + const dataV0: TypeV0 = { data: 77 }; + const expectedData: TypeV2 = { + data: [77], + additionalData: 'test', + __version: 2, + }; + const mr = new MigrationRunner(migrations); + + const resultData = mr.runMigration(dataV0); + expect(resultData).toStrictEqual(expectedData); + }); + }); +}); diff --git a/src/base/MigrationRunner.ts b/src/base/MigrationRunner.ts new file mode 100644 index 0000000..994f728 --- /dev/null +++ b/src/base/MigrationRunner.ts @@ -0,0 +1,92 @@ +import { SchemaMigration } from '../types/SchemaMigration'; +import { SchemaType } from '../types/SchemaType'; +import { last } from '../helpers/ArrayHelper'; + +export default class MigrationRunner { + private schemaMigrations: SchemaMigration[] = []; + private genErrorMsg = (message: string) => `[MigrationRunner] ${message}`; + + constructor(schemaMigrations: SchemaMigration[]) { + const schemaMigrationsSorted = schemaMigrations.slice(); + schemaMigrationsSorted.sort((a, b) => a.version - b.version); + + if (!schemaMigrationsSorted.length) { + throw new Error(this.genErrorMsg('schemaMigrations can`t be empty')); + } + + if (!schemaMigrationsSorted.find((i) => i.version === 0)) { + throw new Error( + this.genErrorMsg( + 'schemaMigrations should have migration for `version=0`' + ) + ); + } + + const hasAllVersions = schemaMigrations.every( + (i, idx) => i.version === idx + ); + if (!hasAllVersions) { + throw new Error( + this.genErrorMsg('Each version should go one after the other') + ); + } + + this.schemaMigrations = schemaMigrationsSorted; + } + + runMigration(data: T) { + let newData: T = data; + const lastVersion = last(this.schemaMigrations)?.version; + + if (lastVersion === undefined) { + throw new Error('There are no migrations'); + } + + // if (!('__version' in data)) { + // const migration = this.schemaMigrations.find((i) => i.version === 1); + // if (!migration) { + // throw new Error(); + // } + // newData = migration.migration?.(data); + // } else { + // newData = data; + // } + + // if (newData === undefined || newData.__version === undefined) { + // throw new Error(); + // } + + let nextVersion = + newData.__version !== undefined ? newData.__version + 1 : 1; + + while (true) { + if (nextVersion > lastVersion) { + return newData; + } + + const migration = this.schemaMigrations.find( + (m) => m.version === nextVersion + ); + + if (!migration) { + throw new Error( + `Migration from ${newData.__version} to ${nextVersion} not found` + ); + } + + if (migration?.migration) { + const nextData: T = migration.migration(newData); + + if (nextData === undefined) { + throw new Error(); + } + if (nextData.__version === undefined) { + throw new Error(); + } + + newData = nextData; + nextVersion = nextData.__version + 1; + } + } + } +} diff --git a/src/base/repositories/AbstractFileRepository.ts b/src/base/repositories/AbstractFileRepository.ts index b2eefc1..2524cbf 100644 --- a/src/base/repositories/AbstractFileRepository.ts +++ b/src/base/repositories/AbstractFileRepository.ts @@ -5,7 +5,8 @@ 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'; const APP_DIR = process.env.NODE_ENV === 'development' @@ -18,8 +19,9 @@ 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 get logPrefix() { const filePath = !this.saveInRoot ? this.dirWithProfileData : ''; @@ -55,7 +57,7 @@ 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 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/ProjectValidator.ts b/src/modules/projects/ProjectValidator.ts new file mode 100644 index 0000000..3bd65b5 --- /dev/null +++ b/src/modules/projects/ProjectValidator.ts @@ -0,0 +1,5 @@ +import Ajv from 'ajv'; +import ProjectSchemaV0 from './schemas/ProjectSchemaV0'; + +const ajv = new Ajv({ allErrors: true }); +export const validate = ajv.compile(ProjectSchemaV0); diff --git a/src/modules/projects/migrations/MigrationV1.ts b/src/modules/projects/migrations/MigrationV1.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/projects/schemas/ProjectSchemaV0.ts b/src/modules/projects/schemas/ProjectSchemaV0.ts new file mode 100644 index 0000000..3e779f7 --- /dev/null +++ b/src/modules/projects/schemas/ProjectSchemaV0.ts @@ -0,0 +1,26 @@ +import { JSONSchemaType } from 'ajv'; +import ProjectModel from '../models/ProjectModel'; + +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 default ProjectSchemaV0; diff --git a/src/modules/projects/types/ProjectModelV0.ts b/src/modules/projects/types/ProjectModelV0.ts new file mode 100644 index 0000000..7a290dd --- /dev/null +++ b/src/modules/projects/types/ProjectModelV0.ts @@ -0,0 +1,6 @@ +export interface IJsonProjectItem extends ITreeItemWithParent { + color: string; + expanded: boolean; + deletable: boolean; + children?: IJsonProjectItem[]; +} 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..005f299 --- /dev/null +++ b/src/modules/settings/consts.ts @@ -0,0 +1,10 @@ +import { SettingsV1 } from './types/SettingsV1'; + +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..2193c76 --- /dev/null +++ b/src/modules/settings/migrations/MigrationV1.ts @@ -0,0 +1,8 @@ +import { SettingsV0 } from '../types/SettingsV0'; +import { SettingsV1 } from '../types/SettingsV1'; + +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..352c426 --- /dev/null +++ b/src/modules/settings/migrations/index.ts @@ -0,0 +1,9 @@ +import migrationV1 from './MigrationV1'; +import SettingsSchemaV1 from '../schemas/SettingsSchemaV1'; +import SettingsSchemaV0 from '../schemas/SettingsSchemaV1'; +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..ccb97d7 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/SettingsV1'; +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/SettingsSchemaV1.ts b/src/modules/settings/schemas/SettingsSchemaV1.ts new file mode 100644 index 0000000..7e7bb09 --- /dev/null +++ b/src/modules/settings/schemas/SettingsSchemaV1.ts @@ -0,0 +1,27 @@ +import { JSONSchemaType } from 'ajv'; +import SettingsModel from '../models/SettingsModel'; + +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', + ], +}; + +export default SettingsSchemaV1; diff --git a/src/modules/settings/types/SettingsV0.ts b/src/modules/settings/types/SettingsV0.ts new file mode 100644 index 0000000..162ddfc --- /dev/null +++ b/src/modules/settings/types/SettingsV0.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/SettingsV1.ts b/src/modules/settings/types/SettingsV1.ts new file mode 100644 index 0000000..36d0011 --- /dev/null +++ b/src/modules/settings/types/SettingsV1.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/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..2fdacdc --- /dev/null +++ b/src/types/SchemaMigration.ts @@ -0,0 +1,5 @@ +export type SchemaMigration = { + version: number; + schema: any; + migration?: (data: TIn) => TOut; +}; 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" From b64dc781626856477429b414863276646105da46 Mon Sep 17 00:00:00 2001 From: yadro Date: Sun, 2 Jan 2022 16:06:55 +0300 Subject: [PATCH 2/6] WIP --- src/base/MigrationRunner.test.ts | 30 +++++++++++++++ src/base/MigrationRunner.ts | 66 ++++++++++++++++++++++++-------- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/src/base/MigrationRunner.test.ts b/src/base/MigrationRunner.test.ts index e4bf2a6..9172057 100644 --- a/src/base/MigrationRunner.test.ts +++ b/src/base/MigrationRunner.test.ts @@ -96,5 +96,35 @@ describe('MigrationRunner tests', () => { const resultData = mr.runMigration(dataV0); expect(resultData).toStrictEqual(expectedData); }); + + test('Test when there is schema validation error', () => { + 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( + [ + '[MigrationRunner] Schema validation error "version=0". Found next errors:', + '"/": "must have required property \'data\'"', + '"/text": "must be string"', + '"/prop": "must have required property \'test\'"', + ].join('\n') + ); + }); }); }); diff --git a/src/base/MigrationRunner.ts b/src/base/MigrationRunner.ts index 994f728..3a2729b 100644 --- a/src/base/MigrationRunner.ts +++ b/src/base/MigrationRunner.ts @@ -1,17 +1,31 @@ +import Ajv from 'ajv'; import { SchemaMigration } from '../types/SchemaMigration'; import { SchemaType } from '../types/SchemaType'; import { last } from '../helpers/ArrayHelper'; +enum ErrorCodes { + NoMigrations, + NoZeroMigration, + IncorrectMigrationsOrder, +} + export default class MigrationRunner { private schemaMigrations: SchemaMigration[] = []; - private genErrorMsg = (message: string) => `[MigrationRunner] ${message}`; + private ajv: Ajv; + private genErrorMsg = (error: ErrorCodes | undefined, message: string) => + `[MigrationRunner] ${error ?? -1} ${message}`; constructor(schemaMigrations: SchemaMigration[]) { const schemaMigrationsSorted = schemaMigrations.slice(); schemaMigrationsSorted.sort((a, b) => a.version - b.version); if (!schemaMigrationsSorted.length) { - throw new Error(this.genErrorMsg('schemaMigrations can`t be empty')); + throw new Error( + this.genErrorMsg( + ErrorCodes.NoMigrations, + 'schemaMigrations can`t be empty' + ) + ); } if (!schemaMigrationsSorted.find((i) => i.version === 0)) { @@ -32,6 +46,7 @@ export default class MigrationRunner { } this.schemaMigrations = schemaMigrationsSorted; + this.ajv = new Ajv({ allErrors: true }); } runMigration(data: T) { @@ -42,19 +57,24 @@ export default class MigrationRunner { throw new Error('There are no migrations'); } - // if (!('__version' in data)) { - // const migration = this.schemaMigrations.find((i) => i.version === 1); - // if (!migration) { - // throw new Error(); - // } - // newData = migration.migration?.(data); - // } else { - // newData = data; - // } - - // if (newData === undefined || newData.__version === undefined) { - // throw new Error(); - // } + const firstMigration = this.schemaMigrations.find((i) => i.version === 0); + if (!firstMigration) { + throw new Error(); + } + const validate = this.ajv.compile(firstMigration.schema); + const validateResult = validate(newData); + if (!validateResult) { + throw new Error( + this.genErrorMsg( + `Schema validation error "version=0". Found next errors:\n${validate.errors + ?.map( + (e) => + `"${e.instancePath ? e.instancePath : '/'}": "${e.message}"` + ) + ?.join('\n')}` + ) + ); + } let nextVersion = newData.__version !== undefined ? newData.__version + 1 : 1; @@ -70,7 +90,9 @@ export default class MigrationRunner { if (!migration) { throw new Error( - `Migration from ${newData.__version} to ${nextVersion} not found` + this.genErrorMsg( + `Migration from ${newData.__version} to ${nextVersion} not found` + ) ); } @@ -84,6 +106,18 @@ export default class MigrationRunner { throw new Error(); } + const validate = this.ajv.compile(migration.schema); + const validateObj = validate(nextData); + if (!validateObj) { + throw new Error( + this.genErrorMsg( + `Schema validation error. version=${nextVersion}: ${validate.errors?.join( + ', ' + )}` + ) + ); + } + newData = nextData; nextVersion = nextData.__version + 1; } From 67d33eaf49307059802563deb79ed8deb0baed86 Mon Sep 17 00:00:00 2001 From: yadro Date: Mon, 3 Jan 2022 11:19:37 +0300 Subject: [PATCH 3/6] Migration codes --- src/base/MigrationRunner.test.ts | 5 +- src/base/MigrationRunner.ts | 86 +++++++++++++++++--------------- src/types/MigrationErrorCodes.ts | 9 ++++ 3 files changed, 60 insertions(+), 40 deletions(-) create mode 100644 src/types/MigrationErrorCodes.ts diff --git a/src/base/MigrationRunner.test.ts b/src/base/MigrationRunner.test.ts index 9172057..95f629c 100644 --- a/src/base/MigrationRunner.test.ts +++ b/src/base/MigrationRunner.test.ts @@ -99,6 +99,7 @@ describe('MigrationRunner tests', () => { test('Test when there is schema validation error', () => { type TypeV0 = { data: number; text: string; prop: { test: 1 } }; + const schemaV0: JSONSchemaType = { type: 'object', properties: { @@ -112,14 +113,16 @@ describe('MigrationRunner tests', () => { }, 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( [ - '[MigrationRunner] Schema validation error "version=0". Found next errors:', + 'Migration to version=0. Schema validation error. Found next errors:', '"/": "must have required property \'data\'"', '"/text": "must be string"', '"/prop": "must have required property \'test\'"', diff --git a/src/base/MigrationRunner.ts b/src/base/MigrationRunner.ts index 3a2729b..bb27af5 100644 --- a/src/base/MigrationRunner.ts +++ b/src/base/MigrationRunner.ts @@ -1,19 +1,28 @@ -import Ajv from 'ajv'; +import Ajv, { ValidateFunction } from 'ajv'; import { SchemaMigration } from '../types/SchemaMigration'; import { SchemaType } from '../types/SchemaType'; import { last } from '../helpers/ArrayHelper'; - -enum ErrorCodes { - NoMigrations, - NoZeroMigration, - IncorrectMigrationsOrder, -} +import MigrationErrorCodes from '../types/MigrationErrorCodes'; export default class MigrationRunner { private schemaMigrations: SchemaMigration[] = []; private ajv: Ajv; - private genErrorMsg = (error: ErrorCodes | undefined, message: string) => - `[MigrationRunner] ${error ?? -1} ${message}`; + private genErrorMsg = ( + error: MigrationErrorCodes | undefined, + message: string + ) => `[MigrationRunner] [error=${error ?? -1}] ${message}`; + private genValidationErrors = ( + validate: ValidateFunction, + toVersion: number + ) => + this.genErrorMsg( + MigrationErrorCodes.ValidationFailed, + `Migration to version=${toVersion}. Schema validation error. Found next errors:\n${validate.errors + ?.map( + (e) => `"${e.instancePath ? e.instancePath : '/'}": "${e.message}"` + ) + ?.join('\n')}` + ); constructor(schemaMigrations: SchemaMigration[]) { const schemaMigrationsSorted = schemaMigrations.slice(); @@ -22,8 +31,8 @@ export default class MigrationRunner { if (!schemaMigrationsSorted.length) { throw new Error( this.genErrorMsg( - ErrorCodes.NoMigrations, - 'schemaMigrations can`t be empty' + MigrationErrorCodes.NoMigrations, + `schemaMigrations can't be empty` ) ); } @@ -31,6 +40,7 @@ export default class MigrationRunner { if (!schemaMigrationsSorted.find((i) => i.version === 0)) { throw new Error( this.genErrorMsg( + MigrationErrorCodes.NoZeroMigration, 'schemaMigrations should have migration for `version=0`' ) ); @@ -41,7 +51,10 @@ export default class MigrationRunner { ); if (!hasAllVersions) { throw new Error( - this.genErrorMsg('Each version should go one after the other') + this.genErrorMsg( + MigrationErrorCodes.IncorrectMigrationsOrder, + 'Each version should go one after the other' + ) ); } @@ -54,44 +67,45 @@ export default class MigrationRunner { const lastVersion = last(this.schemaMigrations)?.version; if (lastVersion === undefined) { - throw new Error('There are no migrations'); + throw new Error( + this.genErrorMsg( + MigrationErrorCodes.NoMigrations, + 'There are no migrations' + ) + ); } - const firstMigration = this.schemaMigrations.find((i) => i.version === 0); + const zeroVersion = 0; + const firstMigration = this.schemaMigrations.find( + (i) => i.version === zeroVersion + ); if (!firstMigration) { throw new Error(); } + const validate = this.ajv.compile(firstMigration.schema); const validateResult = validate(newData); if (!validateResult) { - throw new Error( - this.genErrorMsg( - `Schema validation error "version=0". Found next errors:\n${validate.errors - ?.map( - (e) => - `"${e.instancePath ? e.instancePath : '/'}": "${e.message}"` - ) - ?.join('\n')}` - ) - ); + throw new Error(this.genValidationErrors(validate, zeroVersion)); } - let nextVersion = - newData.__version !== undefined ? newData.__version + 1 : 1; + let fromVersion = newData.__version; + let toVersion = fromVersion !== undefined ? fromVersion + 1 : 1; while (true) { - if (nextVersion > lastVersion) { + if (toVersion > lastVersion) { return newData; } const migration = this.schemaMigrations.find( - (m) => m.version === nextVersion + (m) => m.version === toVersion ); if (!migration) { throw new Error( this.genErrorMsg( - `Migration from ${newData.__version} to ${nextVersion} not found` + MigrationErrorCodes.MigrationNotFound, + `Migration from ${fromVersion} to ${toVersion} not found` ) ); } @@ -107,19 +121,13 @@ export default class MigrationRunner { } const validate = this.ajv.compile(migration.schema); - const validateObj = validate(nextData); - if (!validateObj) { - throw new Error( - this.genErrorMsg( - `Schema validation error. version=${nextVersion}: ${validate.errors?.join( - ', ' - )}` - ) - ); + const validateResult = validate(nextData); + if (!validateResult) { + throw new Error(this.genValidationErrors(validate, toVersion)); } newData = nextData; - nextVersion = nextData.__version + 1; + toVersion = nextData.__version + 1; } } } diff --git a/src/types/MigrationErrorCodes.ts b/src/types/MigrationErrorCodes.ts new file mode 100644 index 0000000..05ed73d --- /dev/null +++ b/src/types/MigrationErrorCodes.ts @@ -0,0 +1,9 @@ +enum MigrationErrorCodes { + NoMigrations = 0, + NoZeroMigration = 1, + IncorrectMigrationsOrder = 2, + ValidationFailed = 3, + MigrationNotFound = 4, +} + +export default MigrationErrorCodes; From 42e32549941de69a81f20fc344fee29a6e815d4e Mon Sep 17 00:00:00 2001 From: yadro Date: Tue, 4 Jan 2022 15:15:13 +0300 Subject: [PATCH 4/6] Fixed migration, updated tests --- src/base/MigrationRunner.test.ts | 122 ++++++++++++++++++++++++++++--- src/base/MigrationRunner.ts | 59 +++++++++------ src/types/MigrationErrorCodes.ts | 1 + src/types/SchemaMigration.ts | 2 +- 4 files changed, 151 insertions(+), 33 deletions(-) diff --git a/src/base/MigrationRunner.test.ts b/src/base/MigrationRunner.test.ts index 95f629c..59ece26 100644 --- a/src/base/MigrationRunner.test.ts +++ b/src/base/MigrationRunner.test.ts @@ -1,22 +1,23 @@ 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('throw error when schemaMigrations is empty', () => { + describe('Constructor tests', () => { + test(`Throw ErrorCode=${MigrationErrorCodes.NoMigrations} NoMigrations`, () => { expect(() => new MigrationRunner([])).toThrow( - 'schemaMigrations can`t be empty' + `schemaMigrations can't be empty` ); }); - test('throw error when there is no migration `version=0`', () => { + test(`Throw ErrorCode=${MigrationErrorCodes.NoZeroMigration} NoZeroMigration`, () => { expect(() => new MigrationRunner([{ version: 1, schema: {} }])).toThrow( 'schemaMigrations should have migration for `version=0`' ); }); - test('throw error when versions doesn`t go one after the other', () => { + test(`Throw ErrorCode=${MigrationErrorCodes.IncorrectMigrationsOrder} IncorrectMigrationsOrder`, () => { expect( () => new MigrationRunner([ @@ -27,8 +28,8 @@ describe('MigrationRunner tests', () => { }); }); - describe('migration tests', () => { - test('Test migration', () => { + describe('Migration tests', () => { + test('Test migration with 3 iterations', () => { type TypeV0 = { data: number }; type TypeV1 = { data: number[]; __version: number }; type TypeV2 = { @@ -67,7 +68,7 @@ describe('MigrationRunner tests', () => { schema: schemaV1, migration(item: TypeV0): TypeV1 { return { - data: [item.data], + data: [item.data] as number[], __version: 1, }; }, @@ -97,7 +98,7 @@ describe('MigrationRunner tests', () => { expect(resultData).toStrictEqual(expectedData); }); - test('Test when there is schema validation error', () => { + test(`Throw ErrorCode=${MigrationErrorCodes.ValidationFailed} ValidationFailed`, () => { type TypeV0 = { data: number; text: string; prop: { test: 1 } }; const schemaV0: JSONSchemaType = { @@ -129,5 +130,108 @@ describe('MigrationRunner tests', () => { ].join('\n') ); }); + + test(`Throw ErrorCode=${MigrationErrorCodes.MigrationNotFound} MigrationNotFound`, () => { + 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 {undefined->\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 index bb27af5..1776fad 100644 --- a/src/base/MigrationRunner.ts +++ b/src/base/MigrationRunner.ts @@ -25,10 +25,7 @@ export default class MigrationRunner { ); constructor(schemaMigrations: SchemaMigration[]) { - const schemaMigrationsSorted = schemaMigrations.slice(); - schemaMigrationsSorted.sort((a, b) => a.version - b.version); - - if (!schemaMigrationsSorted.length) { + if (!schemaMigrations.length) { throw new Error( this.genErrorMsg( MigrationErrorCodes.NoMigrations, @@ -37,6 +34,9 @@ export default class MigrationRunner { ); } + const schemaMigrationsSorted = schemaMigrations.slice(); + schemaMigrationsSorted.sort((a, b) => a.version - b.version); + if (!schemaMigrationsSorted.find((i) => i.version === 0)) { throw new Error( this.genErrorMsg( @@ -80,7 +80,12 @@ export default class MigrationRunner { (i) => i.version === zeroVersion ); if (!firstMigration) { - throw new Error(); + throw new Error( + this.genErrorMsg( + MigrationErrorCodes.NoMigrations, + `There are no migrations 'version=${zeroVersion}'` + ) + ); } const validate = this.ajv.compile(firstMigration.schema); @@ -101,34 +106,42 @@ export default class MigrationRunner { (m) => m.version === toVersion ); - if (!migration) { + if (!migration?.migration) { throw new Error( this.genErrorMsg( MigrationErrorCodes.MigrationNotFound, - `Migration from ${fromVersion} to ${toVersion} not found` + `Migration {${fromVersion}->${toVersion}} not found` ) ); } - if (migration?.migration) { - const nextData: T = migration.migration(newData); - - if (nextData === undefined) { - throw new Error(); - } - if (nextData.__version === undefined) { - throw new Error(); - } + const nextData: T = migration.migration(newData); - const validate = this.ajv.compile(migration.schema); - const validateResult = validate(nextData); - if (!validateResult) { - throw new Error(this.genValidationErrors(validate, toVersion)); - } + if (nextData === undefined) { + throw new Error( + this.genErrorMsg( + MigrationErrorCodes.MigrationFailed, + `After run migration {${fromVersion}->${toVersion}}, migration returned 'undefined'` + ) + ); + } + if (nextData.__version === undefined) { + throw new Error( + this.genErrorMsg( + MigrationErrorCodes.MigrationFailed, + `After run migration {${fromVersion}->${toVersion}}, migration returned 'data' without '__version'` + ) + ); + } - newData = nextData; - toVersion = nextData.__version + 1; + const validate = this.ajv.compile(migration.schema); + const validateResult = validate(nextData); + if (!validateResult) { + throw new Error(this.genValidationErrors(validate, toVersion)); } + + newData = nextData; + toVersion = nextData.__version + 1; } } } diff --git a/src/types/MigrationErrorCodes.ts b/src/types/MigrationErrorCodes.ts index 05ed73d..f53b322 100644 --- a/src/types/MigrationErrorCodes.ts +++ b/src/types/MigrationErrorCodes.ts @@ -4,6 +4,7 @@ enum MigrationErrorCodes { IncorrectMigrationsOrder = 2, ValidationFailed = 3, MigrationNotFound = 4, + MigrationFailed = 5, } export default MigrationErrorCodes; diff --git a/src/types/SchemaMigration.ts b/src/types/SchemaMigration.ts index 2fdacdc..0c516be 100644 --- a/src/types/SchemaMigration.ts +++ b/src/types/SchemaMigration.ts @@ -1,5 +1,5 @@ export type SchemaMigration = { version: number; schema: any; - migration?: (data: TIn) => TOut; + migration?: (data: any) => any; }; From 34e19b2ffe4c25719dc9caae228a78b5156b9921 Mon Sep 17 00:00:00 2001 From: yadro Date: Tue, 4 Jan 2022 19:06:48 +0300 Subject: [PATCH 5/6] Upd migration, added tests --- .vscode/settings.json | 2 +- src/base/MigrationRunner.test.ts | 140 ++++++++------ src/base/MigrationRunner.ts | 177 ++++++++---------- src/modules/settings/migrations/index.ts | 2 +- .../settings/schemas/SettingsSchemaV0.ts | 25 +++ .../settings/schemas/SettingsSchemaV1.ts | 4 +- 6 files changed, 191 insertions(+), 159 deletions(-) create mode 100644 src/modules/settings/schemas/SettingsSchemaV0.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 545b84a..497a84d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,5 +36,5 @@ "*.{css,sass,scss}.d.ts": true }, - "cSpell.words": ["Popconfirm", "Sider"] + "cSpell.words": ["Popconfirm", "Sider", "Yadro"] } diff --git a/src/base/MigrationRunner.test.ts b/src/base/MigrationRunner.test.ts index 59ece26..3ce1ae3 100644 --- a/src/base/MigrationRunner.test.ts +++ b/src/base/MigrationRunner.test.ts @@ -5,6 +5,15 @@ 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` @@ -29,66 +38,79 @@ describe('MigrationRunner tests', () => { }); describe('Migration tests', () => { - test('Test migration with 3 iterations', () => { - 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' }, + 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, + }; }, - 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' }, + }, + { + version: 2, + schema: schemaV2, + migration(item: TypeV1): TypeV2 { + return { + data: item.data, + additionalData: 'test', + __version: 2, + }; }, - required: ['data', '__version', 'additionalData'], + }, + ]; + + test('Test migration with 3 iterations', () => { + const dataV0: TypeV0 = { data: 777 }; + const expectedData: TypeV2 = { + data: [777], + additionalData: 'test', + __version: 2, }; - const migrations: SchemaMigration[] = [ - { version: 0, schema: schemaV0 }, - { - version: 1, - schema: schemaV1, - migration(item: TypeV0): TypeV1 { - return { - data: [item.data] as number[], - __version: 1, - }; - }, - }, - { - version: 2, - schema: schemaV2, - migration(item: TypeV1): TypeV2 { - return { - data: item.data, - additionalData: 'test', - __version: 2, - }; - }, - }, - ]; + const mr = new MigrationRunner(migrations); - const dataV0: TypeV0 = { data: 77 }; + const resultData = mr.runMigration(dataV0); + expect(resultData).toStrictEqual(expectedData); + }); + + test('Test continue migration', () => { + const dataV0: TypeV1 = { data: [777], __version: 1 }; const expectedData: TypeV2 = { - data: [77], + data: [777], additionalData: 'test', __version: 2, }; @@ -97,7 +119,9 @@ describe('MigrationRunner tests', () => { 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 } }; @@ -123,7 +147,7 @@ describe('MigrationRunner tests', () => { expect(() => mr.runMigration(dataV0)).toThrow( [ - 'Migration to version=0. Schema validation error. Found next errors:', + '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\'"', @@ -131,7 +155,7 @@ describe('MigrationRunner tests', () => { ); }); - test(`Throw ErrorCode=${MigrationErrorCodes.MigrationNotFound} MigrationNotFound`, () => { + test(`Throw ErrorCode=${MigrationErrorCodes.MigrationNotFound} MigrationNotFound - no migration for version=1`, () => { type TypeV0 = { data: number }; const schemaV0: JSONSchemaType = { @@ -152,7 +176,7 @@ describe('MigrationRunner tests', () => { const mr = new MigrationRunner(migrations); expect(() => mr.runMigration(dataV0)).toThrow( - /Migration {undefined->\d} not found/ + /Migration {\d->\d} not found/ ); }); diff --git a/src/base/MigrationRunner.ts b/src/base/MigrationRunner.ts index 1776fad..4a79850 100644 --- a/src/base/MigrationRunner.ts +++ b/src/base/MigrationRunner.ts @@ -1,62 +1,63 @@ -import Ajv, { ValidateFunction } from 'ajv'; +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; - private genErrorMsg = ( - error: MigrationErrorCodes | undefined, - message: string - ) => `[MigrationRunner] [error=${error ?? -1}] ${message}`; - private genValidationErrors = ( - validate: ValidateFunction, - toVersion: number - ) => - this.genErrorMsg( - MigrationErrorCodes.ValidationFailed, - `Migration to version=${toVersion}. Schema validation error. Found next errors:\n${validate.errors - ?.map( - (e) => `"${e.instancePath ? e.instancePath : '/'}": "${e.message}"` - ) - ?.join('\n')}` - ); constructor(schemaMigrations: SchemaMigration[]) { - if (!schemaMigrations.length) { - throw new Error( - this.genErrorMsg( - MigrationErrorCodes.NoMigrations, - `schemaMigrations can't be empty` - ) - ); - } + migrationAssert( + schemaMigrations.length, + MigrationErrorCodes.NoMigrations, + `schemaMigrations can't be empty` + ); const schemaMigrationsSorted = schemaMigrations.slice(); schemaMigrationsSorted.sort((a, b) => a.version - b.version); - if (!schemaMigrationsSorted.find((i) => i.version === 0)) { - throw new Error( - this.genErrorMsg( - MigrationErrorCodes.NoZeroMigration, - 'schemaMigrations should have migration for `version=0`' - ) - ); - } + 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 ); - if (!hasAllVersions) { - throw new Error( - this.genErrorMsg( - MigrationErrorCodes.IncorrectMigrationsOrder, - 'Each version should go one after the other' - ) - ); - } + + migrationAssert( + hasAllVersions, + MigrationErrorCodes.IncorrectMigrationsOrder, + 'Each version should go one after the other' + ); this.schemaMigrations = schemaMigrationsSorted; this.ajv = new Ajv({ allErrors: true }); @@ -64,41 +65,33 @@ export default class MigrationRunner { runMigration(data: T) { let newData: T = data; - const lastVersion = last(this.schemaMigrations)?.version; - - if (lastVersion === undefined) { - throw new Error( - this.genErrorMsg( - MigrationErrorCodes.NoMigrations, - 'There are no migrations' - ) - ); - } + let fromVersion = newData.__version || 0; + let toVersion = fromVersion !== undefined ? fromVersion + 1 : 1; - const zeroVersion = 0; - const firstMigration = this.schemaMigrations.find( - (i) => i.version === zeroVersion + const latestVersion = last(this.schemaMigrations)?.version; + + migrationAssert( + latestVersion !== undefined, + MigrationErrorCodes.NoMigrations, + 'There are no migrations' ); - if (!firstMigration) { - throw new Error( - this.genErrorMsg( - MigrationErrorCodes.NoMigrations, - `There are no migrations 'version=${zeroVersion}'` - ) - ); - } - const validate = this.ajv.compile(firstMigration.schema); - const validateResult = validate(newData); - if (!validateResult) { - throw new Error(this.genValidationErrors(validate, zeroVersion)); - } + const migration = this.schemaMigrations.find( + (i) => i.version === fromVersion + ); - let fromVersion = newData.__version; - let toVersion = fromVersion !== undefined ? fromVersion + 1 : 1; + 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 > lastVersion) { + if (toVersion > latestVersion) { return newData; } @@ -106,39 +99,29 @@ export default class MigrationRunner { (m) => m.version === toVersion ); - if (!migration?.migration) { - throw new Error( - this.genErrorMsg( - MigrationErrorCodes.MigrationNotFound, - `Migration {${fromVersion}->${toVersion}} not found` - ) - ); - } + migrationAssert( + migration?.migration, + MigrationErrorCodes.MigrationNotFound, + `Migration {${fromVersion}->${toVersion}} not found` + ); const nextData: T = migration.migration(newData); - if (nextData === undefined) { - throw new Error( - this.genErrorMsg( - MigrationErrorCodes.MigrationFailed, - `After run migration {${fromVersion}->${toVersion}}, migration returned 'undefined'` - ) - ); - } - if (nextData.__version === undefined) { - throw new Error( - this.genErrorMsg( - MigrationErrorCodes.MigrationFailed, - `After run migration {${fromVersion}->${toVersion}}, migration returned 'data' without '__version'` - ) - ); - } + 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); - if (!validateResult) { - throw new Error(this.genValidationErrors(validate, toVersion)); - } + migrationAssertShowValidationErrors(validateResult, validate, toVersion); newData = nextData; toVersion = nextData.__version + 1; diff --git a/src/modules/settings/migrations/index.ts b/src/modules/settings/migrations/index.ts index 352c426..e7bd812 100644 --- a/src/modules/settings/migrations/index.ts +++ b/src/modules/settings/migrations/index.ts @@ -1,6 +1,6 @@ import migrationV1 from './MigrationV1'; +import SettingsSchemaV0 from '../schemas/SettingsSchemaV0'; import SettingsSchemaV1 from '../schemas/SettingsSchemaV1'; -import SettingsSchemaV0 from '../schemas/SettingsSchemaV1'; import { SchemaMigration } from '../../../types/SchemaMigration'; export const schemaMigrations: SchemaMigration[] = [ diff --git a/src/modules/settings/schemas/SettingsSchemaV0.ts b/src/modules/settings/schemas/SettingsSchemaV0.ts new file mode 100644 index 0000000..6e7ea2f --- /dev/null +++ b/src/modules/settings/schemas/SettingsSchemaV0.ts @@ -0,0 +1,25 @@ +import { JSONSchemaType } from 'ajv'; +import { SettingsV0 } from '../types/SettingsV0'; + +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', + ], +}; + +export default SettingsSchemaV0; diff --git a/src/modules/settings/schemas/SettingsSchemaV1.ts b/src/modules/settings/schemas/SettingsSchemaV1.ts index 7e7bb09..489aa76 100644 --- a/src/modules/settings/schemas/SettingsSchemaV1.ts +++ b/src/modules/settings/schemas/SettingsSchemaV1.ts @@ -1,7 +1,7 @@ import { JSONSchemaType } from 'ajv'; -import SettingsModel from '../models/SettingsModel'; +import { SettingsV1 } from '../types/SettingsV1'; -const SettingsSchemaV1: JSONSchemaType = { +const SettingsSchemaV1: JSONSchemaType = { type: 'object', properties: { __version: { type: 'number' }, From a9af012308fabce321d9c29847af916d28624c39 Mon Sep 17 00:00:00 2001 From: yadro Date: Wed, 12 Jan 2022 19:14:59 +0300 Subject: [PATCH 6/6] Migrations WIP --- .vscode/settings.json | 2 +- src/base/AbstractFactory.ts | 4 +- src/base/MigrationRunner.ts | 6 +- .../repositories/AbstractFileRepository.ts | 9 ++- src/modules/projects/ProjectFactory.ts | 4 +- src/modules/projects/ProjectRepository.ts | 6 +- src/modules/projects/ProjectService.ts | 17 ++--- src/modules/projects/ProjectStore.ts | 5 +- src/modules/projects/ProjectTypes.ts | 4 + src/modules/projects/ProjectValidator.ts | 5 -- .../projects/migrations/MigrationV1.ts | 8 ++ src/modules/projects/migrations/index.ts | 8 ++ .../projects/models/DefaultProjects.ts | 26 +++++++ .../projects/schemas/ProjectSchemaV0.ts | 9 ++- .../projects/schemas/ProjectSchemaV1.ts | 36 +++++++++ src/modules/projects/schemas/index.ts | 2 + src/modules/projects/types/ProjectModelV0.ts | 6 -- src/modules/projects/types/ProjectTypeV0.ts | 10 +++ src/modules/projects/types/ProjectTypeV1.ts | 13 ++++ src/modules/projects/types/index.ts | 2 + src/modules/settings/consts.ts | 2 +- .../settings/migrations/MigrationV1.ts | 3 +- src/modules/settings/migrations/index.ts | 3 +- src/modules/settings/models/SettingsModel.ts | 2 +- .../settings/schemas/SettingsSchemaV0.ts | 6 +- .../settings/schemas/SettingsSchemaV1.ts | 6 +- src/modules/settings/schemas/index.ts | 2 + .../{SettingsV0.ts => SettingsTypeV0.ts} | 0 .../{SettingsV1.ts => SettingsTypeV1.ts} | 0 src/modules/settings/types/index.ts | 2 + src/modules/tasks/TaskRepository.ts | 2 + src/modules/tasks/migrations/MigrationV1.ts | 8 ++ src/modules/tasks/migrations/index.ts | 8 ++ src/modules/tasks/models/TaskInMyDay.ts | 4 +- src/modules/tasks/schemas/TaskSchemaV0.ts | 67 +++++++++++++++++ src/modules/tasks/schemas/TaskSchemaV1.ts | 75 +++++++++++++++++++ src/modules/tasks/schemas/index.ts | 6 ++ src/modules/tasks/types/TaskTypeV0.ts | 23 ++++++ src/modules/tasks/types/TaskTypeV1.ts | 27 +++++++ src/modules/tasks/types/index.ts | 2 + src/types/IDragInfo.ts | 2 +- 41 files changed, 381 insertions(+), 51 deletions(-) create mode 100644 src/modules/projects/ProjectTypes.ts delete mode 100644 src/modules/projects/ProjectValidator.ts create mode 100644 src/modules/projects/migrations/index.ts create mode 100644 src/modules/projects/models/DefaultProjects.ts create mode 100644 src/modules/projects/schemas/ProjectSchemaV1.ts create mode 100644 src/modules/projects/schemas/index.ts delete mode 100644 src/modules/projects/types/ProjectModelV0.ts create mode 100644 src/modules/projects/types/ProjectTypeV0.ts create mode 100644 src/modules/projects/types/ProjectTypeV1.ts create mode 100644 src/modules/projects/types/index.ts create mode 100644 src/modules/settings/schemas/index.ts rename src/modules/settings/types/{SettingsV0.ts => SettingsTypeV0.ts} (100%) rename src/modules/settings/types/{SettingsV1.ts => SettingsTypeV1.ts} (100%) create mode 100644 src/modules/settings/types/index.ts create mode 100644 src/modules/tasks/migrations/MigrationV1.ts create mode 100644 src/modules/tasks/migrations/index.ts create mode 100644 src/modules/tasks/schemas/TaskSchemaV0.ts create mode 100644 src/modules/tasks/schemas/TaskSchemaV1.ts create mode 100644 src/modules/tasks/schemas/index.ts create mode 100644 src/modules/tasks/types/TaskTypeV0.ts create mode 100644 src/modules/tasks/types/TaskTypeV1.ts create mode 100644 src/modules/tasks/types/index.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 497a84d..133b12d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,5 +36,5 @@ "*.{css,sass,scss}.d.ts": true }, - "cSpell.words": ["Popconfirm", "Sider", "Yadro"] + "cSpell.words": ["autorun", "Popconfirm", "Sider", "Yadro"] } 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.ts b/src/base/MigrationRunner.ts index 4a79850..a6ab8b3 100644 --- a/src/base/MigrationRunner.ts +++ b/src/base/MigrationRunner.ts @@ -29,7 +29,7 @@ function migrationAssertShowValidationErrors( ); } -export default class MigrationRunner { +export default class MigrationRunner { private schemaMigrations: SchemaMigration[] = []; private ajv: Ajv; @@ -63,7 +63,7 @@ export default class MigrationRunner { this.ajv = new Ajv({ allErrors: true }); } - runMigration(data: T) { + runMigration(data: T): TRes { let newData: T = data; let fromVersion = newData.__version || 0; let toVersion = fromVersion !== undefined ? fromVersion + 1 : 1; @@ -92,7 +92,7 @@ export default class MigrationRunner { while (true) { if (toVersion > latestVersion) { - return newData; + return newData as TRes; } const migration = this.schemaMigrations.find( diff --git a/src/base/repositories/AbstractFileRepository.ts b/src/base/repositories/AbstractFileRepository.ts index 2524cbf..4e4c8c4 100644 --- a/src/base/repositories/AbstractFileRepository.ts +++ b/src/base/repositories/AbstractFileRepository.ts @@ -7,6 +7,7 @@ import { ipcRenderer } from 'electron'; import FsHelper from '../../helpers/FsHelper'; import PromiseQueue from '../../helpers/PromiseQueueHelper'; import { SchemaMigration } from '../../types/SchemaMigration'; +import MigrationRunner from '../MigrationRunner'; const APP_DIR = process.env.NODE_ENV === 'development' @@ -15,19 +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[] = []; 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; @@ -58,6 +64,7 @@ export default abstract class AbstractFileRepository { const data = fs.readFileSync(this.filePath, { encoding: 'utf-8' }); // TODO handle parse error. Backup file with issues and return defaultValue const parsedData = JSON.parse(data); + return this.migrationRunner.runMigration(parsedData); } return defaultValue; } 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/ProjectValidator.ts b/src/modules/projects/ProjectValidator.ts deleted file mode 100644 index 3bd65b5..0000000 --- a/src/modules/projects/ProjectValidator.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Ajv from 'ajv'; -import ProjectSchemaV0 from './schemas/ProjectSchemaV0'; - -const ajv = new Ajv({ allErrors: true }); -export const validate = ajv.compile(ProjectSchemaV0); diff --git a/src/modules/projects/migrations/MigrationV1.ts b/src/modules/projects/migrations/MigrationV1.ts index e69de29..afdaab2 100644 --- a/src/modules/projects/migrations/MigrationV1.ts +++ 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 index 3e779f7..8f224c9 100644 --- a/src/modules/projects/schemas/ProjectSchemaV0.ts +++ b/src/modules/projects/schemas/ProjectSchemaV0.ts @@ -1,7 +1,7 @@ import { JSONSchemaType } from 'ajv'; -import ProjectModel from '../models/ProjectModel'; +import { ProjectTypeV0, ProjectDataV0 } from '../types'; -const ProjectSchemaV0: JSONSchemaType = { +export const ProjectSchemaV0: JSONSchemaType = { type: 'object', properties: { key: { type: 'string' }, @@ -23,4 +23,7 @@ const ProjectSchemaV0: JSONSchemaType = { required: [], }; -export default ProjectSchemaV0; +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/ProjectModelV0.ts b/src/modules/projects/types/ProjectModelV0.ts deleted file mode 100644 index 7a290dd..0000000 --- a/src/modules/projects/types/ProjectModelV0.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface IJsonProjectItem extends ITreeItemWithParent { - color: string; - expanded: boolean; - deletable: boolean; - children?: IJsonProjectItem[]; -} 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/consts.ts b/src/modules/settings/consts.ts index 005f299..8aace43 100644 --- a/src/modules/settings/consts.ts +++ b/src/modules/settings/consts.ts @@ -1,4 +1,4 @@ -import { SettingsV1 } from './types/SettingsV1'; +import { SettingsV1 } from './types/SettingsTypeV1'; export const DEFAULT_SETTINGS: SettingsV1 = { __version: 1, diff --git a/src/modules/settings/migrations/MigrationV1.ts b/src/modules/settings/migrations/MigrationV1.ts index 2193c76..cc03c11 100644 --- a/src/modules/settings/migrations/MigrationV1.ts +++ b/src/modules/settings/migrations/MigrationV1.ts @@ -1,5 +1,4 @@ -import { SettingsV0 } from '../types/SettingsV0'; -import { SettingsV1 } from '../types/SettingsV1'; +import { SettingsV0, SettingsV1 } from '../types'; export default function migration(data: SettingsV0): SettingsV1 { return Object.assign({}, data, { diff --git a/src/modules/settings/migrations/index.ts b/src/modules/settings/migrations/index.ts index e7bd812..4bb6da6 100644 --- a/src/modules/settings/migrations/index.ts +++ b/src/modules/settings/migrations/index.ts @@ -1,6 +1,5 @@ import migrationV1 from './MigrationV1'; -import SettingsSchemaV0 from '../schemas/SettingsSchemaV0'; -import SettingsSchemaV1 from '../schemas/SettingsSchemaV1'; +import { SettingsSchemaV0, SettingsSchemaV1 } from '../schemas'; import { SchemaMigration } from '../../../types/SchemaMigration'; export const schemaMigrations: SchemaMigration[] = [ diff --git a/src/modules/settings/models/SettingsModel.ts b/src/modules/settings/models/SettingsModel.ts index ccb97d7..21a870e 100644 --- a/src/modules/settings/models/SettingsModel.ts +++ b/src/modules/settings/models/SettingsModel.ts @@ -1,7 +1,7 @@ import { makeObservable, observable } from 'mobx'; import AbstractModel from '../../../base/AbstractModel'; -import { SettingsV1 } from '../types/SettingsV1'; +import { SettingsV1 } from '../types/SettingsTypeV1'; import { DEFAULT_SETTINGS } from '../consts'; export default class SettingsModel extends AbstractModel implements SettingsV1 { diff --git a/src/modules/settings/schemas/SettingsSchemaV0.ts b/src/modules/settings/schemas/SettingsSchemaV0.ts index 6e7ea2f..cc9107c 100644 --- a/src/modules/settings/schemas/SettingsSchemaV0.ts +++ b/src/modules/settings/schemas/SettingsSchemaV0.ts @@ -1,7 +1,7 @@ import { JSONSchemaType } from 'ajv'; -import { SettingsV0 } from '../types/SettingsV0'; +import { SettingsV0 } from '../types/SettingsTypeV0'; -const SettingsSchemaV0: JSONSchemaType = { +export const SettingsSchemaV0: JSONSchemaType = { type: 'object', properties: { currentProfile: { type: 'string' }, @@ -21,5 +21,3 @@ const SettingsSchemaV0: JSONSchemaType = { 'showNotifications', ], }; - -export default SettingsSchemaV0; diff --git a/src/modules/settings/schemas/SettingsSchemaV1.ts b/src/modules/settings/schemas/SettingsSchemaV1.ts index 489aa76..468f0b3 100644 --- a/src/modules/settings/schemas/SettingsSchemaV1.ts +++ b/src/modules/settings/schemas/SettingsSchemaV1.ts @@ -1,7 +1,7 @@ import { JSONSchemaType } from 'ajv'; -import { SettingsV1 } from '../types/SettingsV1'; +import { SettingsV1 } from '../types/SettingsTypeV1'; -const SettingsSchemaV1: JSONSchemaType = { +export const SettingsSchemaV1: JSONSchemaType = { type: 'object', properties: { __version: { type: 'number' }, @@ -23,5 +23,3 @@ const SettingsSchemaV1: JSONSchemaType = { 'showNotifications', ], }; - -export default SettingsSchemaV1; 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/SettingsV0.ts b/src/modules/settings/types/SettingsTypeV0.ts similarity index 100% rename from src/modules/settings/types/SettingsV0.ts rename to src/modules/settings/types/SettingsTypeV0.ts diff --git a/src/modules/settings/types/SettingsV1.ts b/src/modules/settings/types/SettingsTypeV1.ts similarity index 100% rename from src/modules/settings/types/SettingsV1.ts rename to src/modules/settings/types/SettingsTypeV1.ts 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 {