diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d0de2b0..c6ed749 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,20 +7,22 @@ jobs: publish: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [14.x] - steps: - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + - name: Use Node.js + uses: actions/setup-node@v2 with: - node-version: ${{ matrix.node-version }} + node-version-file: '.nvmrc' registry-url: 'https://registry.npmjs.org' - run: npm install - run: npm run lint - run: npm run build - - run: npm publish + - run: npm publish + if: ${{!github.event.release.prerelease}} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_API_KEY }} + - run: npm publish --tag prerelease + if: ${{github.event.release.prerelease}} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_API_KEY }} + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 28e5f4a..571d297 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,15 +3,12 @@ on: [pull_request] jobs: build: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [14.x] steps: - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + - name: Use Node.js + uses: actions/setup-node@v2 with: - node-version: ${{ matrix.node-version }} + node-version-file: '.nvmrc' - run: npm i - run: npm run lint - run: npm run build diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..1a2f5bd --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/* \ No newline at end of file diff --git a/README.md b/README.md index d361610..4c8a15c 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,11 @@ The supported commands are divided into groups according to their target, at thi * The file is stored in the `Migrations` directory within the root of your repository. * Add your migration script in the body of the `run` function using the [Kontent.ai Management SDK](https://github.com/kontent-ai/management-sdk-js) that was injected via the `apiClient` parameter. * To choose between JavaScript and TypeScript when generating the script file, use the `--template-type` option, such as `--template-type "javascript"`. - * The migration template contains an `order` property that is used to run a batch of migrations (range or all) in the specified order. The `order` must be a unique, positive integer or zero. There may be gaps between migrations, for example, the following sequence is perfectly fine 0,3,4,5,10 + * The migration template contains an `order` property that is used to run a batch of migrations (range or all) in the specified order. Order can be one of the two types - `number` or `date`. + * Ordering by `number` has a higher priority. The `order` must be a unique positive integer or zero. There may be gaps between migrations, for example, the following sequence is perfectly fine 0,3,4,5,10 + * Ordering by `date` has a lower priority. To add date ordering use the switch option `-d`. The CLI will generate a new file which name consists of the date in UTC and the name you have specified. Moreover, the property `order` inside the file will be set to the Date accordingly. + * Executing all migrations will firstly migrate migrations with orders specified by number and only then migrations with order specified by date. + * By specifying range you can migrate either number-ordered migrations or date-numbered migrations. They can't be combined. ```typescript // Example migration template @@ -113,6 +117,8 @@ The supported commands are divided into groups according to their target, at thi ``` * `migration run` - Runs a migration script specified by file name (option `--name `), or runs multiple migration scripts in the order specified in the migration files (options `--all` or `--range`). + * By adding `--range` you need to add value in form of `number:number` in case of number ordering or in the format of `Tyyyy-mm-dd-hh-mm-ss:yyyy-mm-dd-hh-mm-ss` in case of date order. + > When using the range with dates, only the year value is mandatory and all other values are optional. It is fine to have a range specified like `T2023-01:2023-02`. It will take all migrations created in January of 2023. Notice the T at the beginning of the Date range. It helps to separate date ordering from number order. * You can execute a migration against a specific project (options `--project --api-key `) or environment stored in the local configuration file (option `--environment `). * After each run of a migration script, the CLI logs the execution into a status file. This file holds data for the next run to prevent running the same migration script more than once. You can choose to override this behavior, for example for debugging purposes, by using the `--force` parameter. * You can choose whether you want to keep executing the migration scripts even if one migration script fails (option `--continue-on-error`) or whether you want to get additional information logged by HttpService into the console (option `--log-http-service-errors-to-console`). diff --git a/package.json b/package.json index 19f3667..777509b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kontent-ai/cli", - "version": "0.6.0", + "version": "0.7.0", "description": "Command line interface tool that can be used for generating and runningKontent.ai migration scripts", "main": "./lib/index.js", "types": "./lib/types/index.d.ts", @@ -46,7 +46,7 @@ "@kontent-ai/backup-manager": "4.0.1", "chalk": "^4.1.2", "dotenv": "^16.0.1", - "yargs": "^17.5.1" + "yargs": "^17.7.0" }, "peerDependencies": { "@kontent-ai/management-sdk": "^3.1.0" @@ -56,7 +56,7 @@ "@babel/preset-env": "~7.18.10", "@babel/preset-typescript": "~7.18.6", "@types/jest": "~28.1.7", - "@types/node": "~18.7.6", + "@types/node": "~18.14.2", "@types/yargs": "~17.0.11", "@typescript-eslint/eslint-plugin": "^5.33.1", "@typescript-eslint/parser": "^5.33.1", @@ -68,6 +68,6 @@ "lint-staged": "^13.0.3", "prettier": "^2.7.1", "ts-jest": "^28.0.8", - "typescript": "~4.7.4" + "typescript": "~4.9.5" } } diff --git a/src/cmds/migration/add.ts b/src/cmds/migration/add.ts index b73e63c..ee39a1e 100644 --- a/src/cmds/migration/add.ts +++ b/src/cmds/migration/add.ts @@ -20,8 +20,14 @@ const addMigrationCommand: yargs.CommandModule = { type: 'string', default: 'javascript', }, + 'timestamp-order': { + alias: 'd', + describe: 'Let order of the migrations be determined by the time it was created.', + type: 'boolean', + default: false, + }, }) - .demandOption(['name', 'template-type']), + .demandOption(['name', 'template-type', 'timestamp-order']), handler: (argv: any) => { if (!['javascript', 'typescript'].includes(argv.templateType)) { console.error(chalk.redBright(`Unexpected template type ${argv.templateType} allowed is [typescript, javascript]`)); @@ -29,7 +35,7 @@ const addMigrationCommand: yargs.CommandModule = { } const templateType = argv.templateType === 'javascript' ? TemplateType.Javascript : TemplateType.TypeScript; - createMigration(argv.name, templateType); + createMigration(argv.name, templateType, argv.timestampOrder); }, }; diff --git a/src/cmds/migration/run.ts b/src/cmds/migration/run.ts index 652ee39..dd71e80 100644 --- a/src/cmds/migration/run.ts +++ b/src/cmds/migration/run.ts @@ -75,8 +75,8 @@ const runMigrationCommand: yargs.CommandModule = { if (!args.all) { if (args.range) { - if (!getRange(args.range)) { - throw new Error(chalk.red('The range has to be a string of a format "number:number" where the first number is less or equal to the second, eg.: "2:5".')); + if (!getRange(args.range) && !getRangeDate(args.range)) { + throw new Error(chalk.red('The range has to be a string of a format "number:number" or "Tyyyy-mm-dd-hh-mm-ss:yyyy-mm-dd-hh-mm-ss" where the first value (to the left to ":") is less or equal to the second, eg.: "2:5".')); } } else if (args.name) { if (!isAllowedExtension(args.name)) { @@ -111,7 +111,7 @@ const runMigrationCommand: yargs.CommandModule = { let apiKey = argv.apiKey; const migrationName = argv.name; const runAll = argv.all; - const runRange = argv.range && exports.getRange(argv.range); + const runRange = argv.range && (exports.getRange(argv.range) || getRangeDate(argv.range)); const logHttpServiceErrorsToConsole = argv.logHttpServiceErrorsToConsole; const continueOnError = argv.continueOnError; let migrationsResults: number = 0; @@ -152,7 +152,7 @@ const runMigrationCommand: yargs.CommandModule = { console.log('No migrations to run.'); } - const sortedMigrationsToRun = migrationsToRun.sort((migrationPrev, migrationNext) => migrationPrev.module.order - migrationNext.module.order); + const sortedMigrationsToRun = migrationsToRun.sort(orderComparator); let executedMigrationsCount = 0; for (const migration of sortedMigrationsToRun) { const migrationResult = await runMigration(migration, apiClient, projectId); @@ -183,7 +183,7 @@ const runMigrationCommand: yargs.CommandModule = { }, }; -export const getRange = (range: string): IRange | null => { +export const getRange = (range: string): IRange | null => { const match = range.match(/^([0-9]+):([0-9]+)$/); if (!match) { return null; @@ -199,6 +199,28 @@ export const getRange = (range: string): IRange | null => { : null; }; +export const getRangeDate = (range: string): IRange | null => { + // format is Tyyyy-mm-dd-hh-mm-ss:yyyy-mm-dd-hh-mm-ss + const match = range.match(/^T(?\d{4}((-\d{2}){0,2}))(?:-(?(\d{2}-){0,2}\d{2}))?:(?\d{4}((-\d{2}){0,2}))(?:-(?(\d{2}-){0,2}\d{2}))?$/); + if (!match) { + return null; + } + + const from = new Date(formatDate(match.groups?.from_date ?? '', match.groups?.from_time ?? '')); + const to = new Date(formatDate(match.groups?.to_date ?? '', match.groups?.to_time ?? '')); + + if (isNaN(from.getTime()) || isNaN(to.getTime())) { + return null; + } + + return from.getTime() <= to.getTime() + ? { + from, + to, + } + : null; +}; + const checkForDuplicates = (migrationsToRun: IMigration[]): void => { const duplicateMigrationsOrder = getDuplicates(migrationsToRun, (migration) => migration.module.order); @@ -210,10 +232,20 @@ const checkForDuplicates = (migrationsToRun: IMigration[]): void => { } }; -const getMigrationsByRange = (migrationsToRun: IMigration[], range: IRange): IMigration[] => { +const getMigrationsByRange = (migrationsToRun: IMigration[], range: IRange): IMigration[] => { const migrations: IMigration[] = []; - for (const migration of migrationsToRun) { + if (isIRangeDate(range)) { + for (const migration of migrationsToRun.filter((x) => x.module.order instanceof Date)) { + if ((migration.module.order as Date).getTime() >= range.from.getTime() && (migration.module.order as Date).getTime() <= range.to.getTime()) { + migrations.push(migration); + } + } + + return migrations.filter(String); + } + + for (const migration of migrationsToRun.filter((x) => typeof x.module.order === 'number')) { if (migration.module.order >= range.from && migration.module.order <= range.to) { migrations.push(migration); } @@ -248,5 +280,32 @@ const skipExecutedMigrations = (migrations: IMigration[], projectId: string): IM return result; }; +const orderComparator = (migrationPrev: IMigration, migrationNext: IMigration) => { + if (typeof migrationPrev.module.order === 'number' && typeof migrationNext.module.order === 'number') { + return migrationPrev.module.order - migrationNext.module.order; + } + + if (migrationPrev.module.order instanceof Date && migrationNext.module.order instanceof Date) { + return migrationPrev.module.order.getTime() - migrationNext.module.order.getTime(); + } + + return typeof migrationPrev.module.order === 'number' ? -1 : 1; +}; + +const formatDate = (date: string, time: string) => { + if (time === '') { + time = '00:00'; + } + if (time.length === 2) { + time = time + ':00'; + } else { + time = time.replaceAll('-', ':'); + } + + return `${date}T${time}Z`; +}; + +const isIRangeDate = (x: IRange): x is IRange => x.from instanceof Date && x.to instanceof Date; + // yargs needs exported command in exports object Object.assign(exports, runMigrationCommand); diff --git a/src/models/range.ts b/src/models/range.ts index a0d5a83..1778ec0 100644 --- a/src/models/range.ts +++ b/src/models/range.ts @@ -1,4 +1,4 @@ -export interface IRange { - from: number; - to: number; +export interface IRange { + from: T; + to: T; } diff --git a/src/models/status.ts b/src/models/status.ts index ce84ffb..0324fff 100644 --- a/src/models/status.ts +++ b/src/models/status.ts @@ -1,7 +1,7 @@ export interface IMigrationStatus { readonly name: string; readonly success: boolean; - readonly order: number; + readonly order: number | Date; readonly time: Date; } diff --git a/src/tests/cmds/run/getRangeDate.test.ts b/src/tests/cmds/run/getRangeDate.test.ts new file mode 100644 index 0000000..3a47e78 --- /dev/null +++ b/src/tests/cmds/run/getRangeDate.test.ts @@ -0,0 +1,62 @@ +import { getRangeDate } from '../../../cmds/migration/run'; + +describe('test getRange', () => { + const correctRanges = [ + { + range: 'T2023:2024', + expected: { + from: new Date(Date.UTC(2023, 0)), + to: new Date(Date.UTC(2024, 0)), + }, + }, + { + range: 'T2023-03:2024', + expected: { + from: new Date(Date.UTC(2023, 2)), + to: new Date(Date.UTC(2024, 0)), + }, + }, + { + range: 'T2023-03:2024-05-23', + expected: { + from: new Date(Date.UTC(2023, 2)), + to: new Date(Date.UTC(2024, 4, 23)), + }, + }, + { + range: 'T2023-03-05-09-05-20:2024-05-23', + expected: { + from: new Date(Date.UTC(2023, 2, 5, 9, 5, 20)), + to: new Date(Date.UTC(2024, 4, 23)), + }, + }, + { + range: 'T2023-03-05-09-05-20:2023-03-05-09-05-21', + expected: { + from: new Date(Date.UTC(2023, 2, 5, 9, 5, 20)), + to: new Date(Date.UTC(2023, 2, 5, 9, 5, 21)), + }, + }, + { + range: 'T2023-03-05-09-05-20:2023-03-05-09-05-20', + expected: { + from: new Date(Date.UTC(2023, 2, 5, 9, 5, 20)), + to: new Date(Date.UTC(2023, 2, 5, 9, 5, 20)), + }, + }, + ]; + + test.each(correctRanges)('test $range to return correct object', ({ range, expected }) => { + const result = getRangeDate(range); + + expect(result).toStrictEqual(expected); + }); + + const malformedRanges = ['T', '2023', 'T2023', 'T2023:', 'T2023:05:2024', 'T2023-13:2024', 'T2023-12:2023-12:02', 'T2023-1:2024', 'T2023-01-02-03-04-05:2023-01-02-03-04-04']; + + test.each(malformedRanges)('test %s to be null', (range) => { + const result = getRangeDate(range); + + expect(result).toBeNull(); + }); +}); diff --git a/src/tests/cmds/run/runMigration.test.ts b/src/tests/cmds/run/runMigration.test.ts new file mode 100644 index 0000000..1a53a9c --- /dev/null +++ b/src/tests/cmds/run/runMigration.test.ts @@ -0,0 +1,70 @@ +import yargs from 'yargs'; +import { IMigration } from '../../../models/migration'; +import * as migrationUtils from '../../../utils/migrationUtils'; +import * as statusManager from '../../../utils/statusManager'; + +const { handler } = require('../../../cmds/migration/run'); + +const migrations: IMigration[] = [ + { + name: 'test1', + module: { + order: new Date('2023-03-25T10:00:00.000Z'), + run: async () => { + console.log('test1'); + }, + }, + }, + { + name: 'test2', + module: { + order: new Date('2023-03-26T10:00:00.000Z'), + run: async () => { + console.log('test2'); + }, + }, + }, + { + name: 'test3', + module: { + order: new Date('2023-03-27T10:00:00.000Z'), + run: async () => { + console.log('test3'); + }, + }, + }, +]; + +jest.spyOn(statusManager, 'markAsCompleted').mockImplementation(() => {}); +jest.spyOn(statusManager, 'loadMigrationsExecutionStatus').mockImplementation(() => {}); +jest.spyOn(migrationUtils, 'loadMigrationFiles').mockReturnValue( + new Promise((resolve) => { + resolve(migrations); + }) +); + +const migration1 = jest.spyOn(migrations[0].module, 'run'); +const migration2 = jest.spyOn(migrations[1].module, 'run'); +const migration3 = jest.spyOn(migrations[2].module, 'run'); + +const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { + return undefined as never; +}); + +describe('run migration command tests', () => { + it('with date range (T2023-03-25:2023-03-27), two migrations should be called', async () => { + const args: any = yargs.parse([], { + apiKey: '', + projectId: '', + range: 'T2023-03-25:2023-03-27', + }); + + await handler(args); + + expect(migration1).toBeCalled(); + expect(migration2).toBeCalled(); + expect(migration3).not.toBeCalled(); + + expect(mockExit).toHaveBeenCalledWith(0); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 8aca29d..19c7d39 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,6 @@ import { ManagementClient } from '@kontent-ai/management-sdk'; export declare interface MigrationModule { - readonly order: number; + readonly order: number | Date; run(apiClient: ManagementClient): Promise; } diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts new file mode 100644 index 0000000..89b0b7b --- /dev/null +++ b/src/utils/dateUtils.ts @@ -0,0 +1,7 @@ +export const formatDateForFileName = (date: Date) => + `${date.getUTCFullYear()}-` + + `${('0' + (date.getUTCMonth() + 1)).slice(-2)}-` + + `${('0' + date.getUTCDate()).slice(-2)}-` + + `${('0' + date.getUTCHours()).slice(-2)}-` + + `${('0' + date.getUTCMinutes()).slice(-2)}-` + + `${('0' + date.getUTCSeconds()).slice(-2)}-`; diff --git a/src/utils/migrationUtils.ts b/src/utils/migrationUtils.ts index b5081db..673cafa 100644 --- a/src/utils/migrationUtils.ts +++ b/src/utils/migrationUtils.ts @@ -7,6 +7,7 @@ import { TemplateType } from '../models/templateType'; import { MigrationModule } from '../types'; import { IMigration } from '../models/migration'; import { markAsCompleted, wasSuccessfullyExecuted } from './statusManager'; +import { formatDateForFileName } from './dateUtils'; export const getMigrationDirectory = (): string => { const migrationDirectory = 'Migrations'; @@ -24,10 +25,11 @@ const ensureMigrationsDirectoryExists = () => { } }; -export const saveMigrationFile = (migrationName: string, migrationData: string, templateType: TemplateType): string => { +export const saveMigrationFile = (migrationName: string, migrationData: string, templateType: TemplateType, orderDate: Date | null): string => { ensureMigrationsDirectoryExists(); const fileExtension = templateType === TemplateType.TypeScript ? '.ts' : '.js'; - const migrationFilepath = getMigrationFilepath(migrationName + fileExtension); + const date = orderDate ? `${formatDateForFileName(orderDate)}` : ''; + const migrationFilepath = getMigrationFilepath(date + migrationName + fileExtension); try { fs.writeFileSync(migrationFilepath, migrationData); @@ -92,11 +94,12 @@ export const runMigration = async (migration: IMigration, client: ManagementClie return 0; }; -export const generateTypedMigration = (): string => { +export const generateTypedMigration = (orderDate?: Date | null): string => { + const order = orderDate ? `new Date('${orderDate.toISOString()}')` : '1'; return `import {MigrationModule} from "@kontent-ai/cli"; const migration: MigrationModule = { - order: 1, + order: ${order}, run: async (apiClient) => { }, }; @@ -105,10 +108,12 @@ export default migration; `; }; -export const generatePlainMigration = (): string => { +export const generatePlainMigration = (orderDate?: Date | null): string => { + const order = orderDate ? `new Date('${orderDate.toISOString()}')` : '1'; + return ` const migration = { - order: 1, + order: ${order}, run: async (apiClient) => { }, }; @@ -117,15 +122,17 @@ module.exports = migration; `; }; -export const createMigration = (migrationName: string, templateType: TemplateType): string => { +export const createMigration = (migrationName: string, templateType: TemplateType, useTimestampOrder: boolean): string => { ensureMigrationsDirectoryExists(); - const generatedMigration = templateType === TemplateType.TypeScript ? generateTypedMigration() : generatePlainMigration(); + const orderDate = true === useTimestampOrder ? new Date() : null; + orderDate?.setMilliseconds(0); + const generatedMigration = templateType === TemplateType.TypeScript ? generateTypedMigration(orderDate) : generatePlainMigration(orderDate); - return saveMigrationFile(migrationName, generatedMigration, templateType); + return saveMigrationFile(migrationName, generatedMigration, templateType, orderDate); }; -export const getDuplicates = (array: T[], key: (obj: T) => number): T[] => { - const allEntries = new Map(); +export const getDuplicates = (array: T[], key: (obj: T) => number | Date): T[] => { + const allEntries = new Map(); let duplicates: T[] = []; for (const item of array) { @@ -147,6 +154,10 @@ export const getMigrationsWithInvalidOrder = (array: const migrationsWithInvalidOrder: T[] = []; for (const migration of array) { + if (migration.module.order instanceof Date) { + continue; + } + if (!Number.isInteger(migration.module.order) || Number(migration.module.order) < 0) { migrationsWithInvalidOrder.push(migration); } diff --git a/src/utils/statusManager.ts b/src/utils/statusManager.ts index 4f850cb..7926509 100644 --- a/src/utils/statusManager.ts +++ b/src/utils/statusManager.ts @@ -23,7 +23,7 @@ const updateMigrationStatus = (projectId: string, migrationStatus: IMigrationSta saveStatusFile(); }; -export const markAsCompleted = (projectId: string, name: string, order: number) => { +export const markAsCompleted = (projectId: string, name: string, order: number | Date) => { const migrationStatus = { name, order,