diff --git a/package.json b/package.json index 46024845..4c84bf9c 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "files": [ "./build/**/*" ], - "main": "./build/src/index.js", - "types": "./build/src/index.d.ts", + "main": "./build/src/public.js", + "types": "./build/src/public.d.ts", "bin": { "data-ops": "./build/src/index.js" }, @@ -70,4 +70,4 @@ "uuid": "^9.0.1", "vitest": "^2.1.1" } -} +} \ No newline at end of file diff --git a/src/commands/syncModel/run/README.md b/src/commands/syncModel/run/README.md index a6d8cbdf..ff91bb86 100644 --- a/src/commands/syncModel/run/README.md +++ b/src/commands/syncModel/run/README.md @@ -4,36 +4,50 @@ > Synchronizing content model might lead to irreversible changes to the environment such as: > - Deletion of content by deleting elements from a content type > - Deletion of used taxonomies +> - Removing roles limitations in workflows (see [known limitations](#known-limitations)) -The `sync-model run` command synchronizes the **source content model** into the **target environment** via [Kontent.ai Management API](https://kontent.ai/learn/docs/apis/openapi/management-api-v2/). The source content can be obtained from an existing Kontent.ai environment (considering you have access to the required credentials) or a folder structure in a required format (see [sync-model export](../export/README.md) command for more information). +The `sync-model run` command synchronizes the **source content model** into the **target environment** via [Kontent.ai Management API](https://kontent.ai/learn/docs/apis/openapi/management-api-v2/). The source content can be obtained from an existing Kontent.ai environment (considering you have access to the required credentials) or a folder structure in a required format (see [sync-model export](../export/README.md) command for more information). Using the CLI command you can filter data by entity type with the mandatory `--entities` parameter. For more advanced filtering, you can use the `entities` parameter through [programmatic sync](#sync-model-programmatically), where you can define custom filter predicate functions for each entity. These functions are optional and, if not provided, the entity WON'T be synced by default. -In the context of this command, the content model is represented by the following entities: `Taxonomies`, `Content Types`, `Content Type Snippets`, `Web Spotlight`, and `Asset Folders`. The command begins by comparing the provided content models and generates patch operations, which are then printed as the **environment diff**. +> [!CAUTION] +> Partial sync requires that all dependent entities are either already present in the environment or included in the sync process. Otherwise, the partial sync may fail, leaving your environment in an incomplete state. -**We strongly encourage you to examine the changes before you proceed with the model synchronization.** Following the diff, a validation is performed to ensure the sync operation can succeed. Should the validation find any inconsistencies, the operation may be terminated. Otherwise, you will be asked to confirmation in order to begin the synchronization. +In the context of this command, the content model is represented by the following entities: `Taxonomies`, `Content Types`, `Content Type Snippets`, `Web Spotlight`, `Asset Folders`, `Collections`, `Spaces`, `Languages` and `Workflows`. The command begins by comparing the provided content models and generates patch operations, which are then printed as the **environment diff**. + +**We strongly encourage you to examine the changes before you proceed with the model synchronization.** Following the diff, a validation is performed to ensure the sync operation can succeed. Should the validation find any inconsistencies, the operation may be terminated. Otherwise, you will be asked for confirmation to begin the synchronization. ## Key principles - Sync matches entities between the source and the target models via a `codename`. - The command does not sync `external_id` properties of content model entities (existing `external_id` cannot be changed and can conflict with other entities). -- If the model contains `guidelines` that reference content items or assets that are not present in the target environment, they will be referenced by their `external_id`(if externalId is non-existent `id` is used as `external_id`) after the synchronization. Remember to migrate any missing content to the target environment either beforehand (preferably) or afterwards to achieve the desired results. +- If the model contains `guidelines` that reference content items or assets that are not present in the target environment, they will be referenced by their `external_id`(if externalId is non-existent `id` is used as `external_id`) after the synchronization. Remember to migrate any missing content to the target environment either beforehand (preferably) or afterward to achieve the desired results. - If `Linked items` or `Rich text element` references non-existent content types, they will be referenced using the `external_id` after the synchronization (one or more entity `codenames` are used to form the `external_id`). ## Sync model conditions To successfully synchronize the content model, we introduced a couple of conditions your environment **must follow** before attempting the sync: - There mustn't be an operation that changes the content type or content type snippet's element type - checked by validation. - There mustn't be an operation deleting a used content type (there is at least one content item of that type) - checked by validation. -- Source content model mustn't reference a deleted taxonomy group - not checked by validation! -- If providing source content model via a folder, you must ensure that the content model is in a valid state - not checked by validation! -- Both environments must have the same status of Web Spotlight (either activated, or deactivated) - not checked by validation! +- There mustn't be an operation deleting a used collection (there is at least one content item in that collection) - checked by validation. +- The source content model mustn't reference a deleted taxonomy group - not checked by validation! +- If providing source content model via a folder, you must ensure that the content model is in a valid state + - Files are partially checked to see whether they meet the MAPI structure using `zod` validation. Not all conditions are checked! (e.g. whether the used codename exists) + +## Known limitations +Using Management API introduces some limitations: +- Snippet element can't be referenced in the same request it's created in. Because of this, the tool can't move it to the correct place in the content type. +- Asset folders cannot be moved so if they are in a different location in the source environment, they are removed and created in the new place. +- Asset folders cannot be deleted (or moved, see the previous point) if they contain assets. The command is not able to check this without loading all the assets in the project so it doesn't check it. Please, make sure that all folders that will be deleted does not contain any assets. You can see what folders will be deleted in the generated diff `sync-model diff ...`. +- Languages cannot be deleted, instead, they are deactivated. Their name and codename are replaced with the first 8 characters of a randomly generated UUID (name and codename have a limit of 25 characters). +- Roles cannot be added or patched via MAPI and are therefore not synced. + - Consequently, **all role restrictions for workflow steps are lost** when adding a new workflow or adjusting an existing one during sync. ## Usage ```bash -npx @kontent-ai/data-ops@latest sync-model run --targetEnvironmentId= --targetApiKey= --sourceEnvironmentId= +npx @kontent-ai/data-ops@latest sync-model run --targetEnvironmentId= --targetApiKey= --sourceEnvironmentId= --entities contentTypes contentTypeSnippets taxonomies --sourceApiKey= ``` OR ```bash -npx @kontent-ai/data-ops@latest sync-model run --targetEnvironmentId= --targetApiKey= --folderName= +npx @kontent-ai/data-ops@latest sync-model run --targetEnvironmentId= --targetApiKey= --folderName= --entities contentTypes contentTypeSnippets taxonomies ``` > [!NOTE] @@ -43,7 +57,8 @@ npx @kontent-ai/data-ops@latest sync-model run --targetEnvironmentId= "sourceEnvironmentId": "", > "sourceApiKey": "", > "targetEnvironmentId": "", -> "targetApiKey": "" +> "targetApiKey": "", +> "entities": ["contentTypes", "contentTypeSnippets", "taxonomies", "collections", "assetFolders", "spaces", "languages", "webSpotlight", "workflows"] > } > ``` @@ -60,28 +75,20 @@ const params: SyncModelRunParams = { sourceEnvironmentId: "", sourceApiKey: "", targetEnvironmentId: "", - targetApiKey: "" + targetApiKey: "", + entities: { + contentTypes: () => true, // will sync + taxonomies: () => false, // won't sync + languages: (lang) => lang.codename === "default" // will sync only codename with default codename + } }; await syncModelRun(params); ``` -## Known limitations -Using Management API introduces some limitations: -- Snippet element can't be referenced in the same request it's created in. Because of this, the tool can't move it to the correct place in the content type. -- Asset folders cannot be moved so if they are in a different location in the source environment, they are removed and created in the new place. -- Asset folders cannot be deleted (or moved, see the previous point) if they contain assets. The command is not able to check this without loading all the assets in the project so it doesn't check it. Please, make sure that all folders that will be deleted does not contain any assets. You can see what assets will be deleted in the generated diff `sync-model diff ...`. -- Languages cannot be deleted, instead, they are deactivated. Their name and codename are replaced with the first 8 characters of a randomly generated UUID (name and codename have a limit of 25 characters). -- Roles cannot be added or patched via MAPI and are therefore not synced. - - Consequently, **all role restrictions for workflow steps are lost** when adding a new workflow or adjusting an existing one during sync. - ## Contributing -When syncing the content model, add, patch, and delete operations must come in a specific order, otherwise, MAPI won't be able to reference some entities. Check the image below for more details. - -![Content model operations order](./images/content_model_operations_order.png) - -To successfully patch a content type, its operations for content groups and elements must also be in a specific order: +To successfully patch a content type, its operations for content groups and elements must be in a specific order: ![Content type operations order](./images/content_type_operations_order.png) ### Taxonomy diff handler diff --git a/src/commands/syncModel/run/images/content_model_operations_order.png b/src/commands/syncModel/run/images/content_model_operations_order.png deleted file mode 100644 index b318a530..00000000 Binary files a/src/commands/syncModel/run/images/content_model_operations_order.png and /dev/null differ diff --git a/src/commands/syncModel/run/run.ts b/src/commands/syncModel/run/run.ts index 55a891a1..f6f35f88 100644 --- a/src/commands/syncModel/run/run.ts +++ b/src/commands/syncModel/run/run.ts @@ -2,12 +2,11 @@ import chalk from "chalk"; import { match, P } from "ts-pattern"; import { logError, logInfo, LogOptions } from "../../../log.js"; +import { syncEntityChoices, SyncEntityName } from "../../../modules/sync/constants/entities.js"; import { printDiff } from "../../../modules/sync/printDiff.js"; -import { sync } from "../../../modules/sync/sync.js"; -import { getDiffModel, SyncModelRunParams, validateTargetEnvironment } from "../../../modules/sync/syncModelRun.js"; +import { SyncEntities, syncModelRunInternal, SyncModelRunParams } from "../../../modules/sync/syncModelRun.js"; import { requestConfirmation } from "../../../modules/sync/utils/consoleHelpers.js"; import { RegisterCommand } from "../../../types/yargs.js"; -import { createClient } from "../../../utils/client.js"; import { simplifyErrors } from "../../../utils/error.js"; const commandName = "run"; @@ -53,6 +52,14 @@ export const register: RegisterCommand = yargs => implies: ["sourceEnvironmentId"], alias: "sk", }) + .option("entities", { + alias: "e", + type: "array", + choices: syncEntityChoices, + describe: `Sync specified entties. Allowed entities are: ${syncEntityChoices.join(", ")}`, + demandOption: "You need to provide the what entities to sync.", + conflicts: "exclude", + }) .option("skipConfirmation", { type: "boolean", describe: "Skip confirmation message.", @@ -64,6 +71,7 @@ type SyncModelRunCliParams = & Readonly<{ targetEnvironmentId: string; targetApiKey: string; + entities: ReadonlyArray; folderName?: string; sourceEnvironmentId?: string; sourceApiKey?: string; @@ -74,47 +82,33 @@ type SyncModelRunCliParams = const syncModelRunCli = async (params: SyncModelRunCliParams) => { const resolvedParams = resolveParams(params); - const targetClient = createClient({ - apiKey: params.targetApiKey, - environmentId: params.targetEnvironmentId, - commandName, - }); - - const diffModel = await getDiffModel(resolvedParams, targetClient, commandName); - - logInfo(params, "standard", "Validating patch operations...\n"); - try { - await validateTargetEnvironment(diffModel, targetClient); - } catch (e) { - logError(params, JSON.stringify(e, Object.getOwnPropertyNames(e))); - process.exit(1); - } + await syncModelRunInternal(resolvedParams, commandName, async (diffModel) => { + printDiff(diffModel, new Set(params.entities), params); - printDiff(diffModel, params); - - const warningMessage = chalk.yellow( - `⚠ Running this operation may result in irreversible changes to the content in environment ${params.targetEnvironmentId}. Mentioned changes might include: + const warningMessage = chalk.yellow( + `⚠ Running this operation may result in irreversible changes to the content in environment ${params.targetEnvironmentId}. Mentioned changes might include: - Removing content due to element deletion OK to proceed y/n? (suppress this message with --sw parameter)\n`, - ); + ); - const confirmed = !params.skipConfirmation ? await requestConfirmation(warningMessage) : true; + const confirmed = !params.skipConfirmation ? await requestConfirmation(warningMessage) : true; - if (!confirmed) { - logInfo(params, "standard", chalk.red("Operation aborted.")); + if (!confirmed) { + logInfo(params, "standard", chalk.red("Operation aborted.")); + process.exit(1); + } + }); + } catch (e) { + logError(params, JSON.stringify(e, Object.getOwnPropertyNames(e))); process.exit(1); } - - await sync( - targetClient, - diffModel, - params, - ); }; -const resolveParams = (params: SyncModelRunCliParams): SyncModelRunParams => - match(params) +const resolveParams = (params: SyncModelRunCliParams): SyncModelRunParams => { + const entities = createSyncEntitiesParameter(params.entities); + + const x = match(params) .with( { sourceEnvironmentId: P.nonNullable, sourceApiKey: P.nonNullable }, ({ sourceEnvironmentId, sourceApiKey }) => ({ ...params, sourceEnvironmentId, sourceApiKey }), @@ -127,3 +121,17 @@ const resolveParams = (params: SyncModelRunCliParams): SyncModelRunParams => ); process.exit(1); }); + + return { ...x, entities }; +}; + +const createSyncEntitiesParameter = ( + entities: ReadonlyArray, +): SyncEntities => { + const filterEntries = [ + ...entities.filter(a => a !== "webSpotlight").map(e => [e, () => true]), + ...entities.includes("webSpotlight") ? [["webSpotlight", true]] : [], + ] as const; + + return Object.fromEntries(filterEntries); +}; diff --git a/src/modules/sync/constants/entities.ts b/src/modules/sync/constants/entities.ts new file mode 100644 index 00000000..e535326c --- /dev/null +++ b/src/modules/sync/constants/entities.ts @@ -0,0 +1,28 @@ +import { SyncEntities } from "../syncModelRun.js"; + +export const syncEntityChoices = [ + "contentTypes", + "contentTypeSnippets", + "taxonomies", + "collections", + "assetFolders", + "spaces", + "languages", + "webSpotlight", + "workflows", +] as const; + +export type SyncEntityName = (typeof syncEntityChoices)[number]; + +// includes transitive dependencies +export const syncEntityDependencies: Record> = { + contentTypes: ["contentTypes", "contentTypeSnippets", "taxonomies"], + contentTypeSnippets: ["contentTypeSnippets", "taxonomies", "contentTypes"], + collections: ["collections"], + taxonomies: ["taxonomies"], + spaces: ["spaces", "collections"], + workflows: ["workflows", "collections", "contentTypes", "contentTypeSnippets", "taxonomies"], + assetFolders: ["assetFolders"], + languages: ["languages"], + webSpotlight: ["webSpotlight", "contentTypes", "contentTypeSnippets", "taxonomies"], +}; diff --git a/src/modules/sync/diff.ts b/src/modules/sync/diff.ts index ff2656cd..c48cb726 100644 --- a/src/modules/sync/diff.ts +++ b/src/modules/sync/diff.ts @@ -156,7 +156,10 @@ const createDiffModel = >( const getLanguageDiffModel = ( sourceLanguages: ReadonlyArray, targetLanguages: ReadonlyArray, -) => { +): DiffModel["languages"] => { + if (sourceLanguages.length === 0 && targetLanguages.length === 0) { + return { added: [], updated: new Map(), deleted: new Set() }; + } const sourceDefaultLanguageCodename = getDefaultLang(sourceLanguages).codename; const targetDefaultLanguageCodename = getDefaultLang(targetLanguages).codename; @@ -207,5 +210,6 @@ const adjustSourceDefaultLanguageCodename = ( const getDefaultLang = (languages: ReadonlyArray) => { const defaultLang = languages.find(l => l.is_default); + return defaultLang ?? throwError(`Language enviroment model does not contain default language`); }; diff --git a/src/modules/sync/diffEnvironments.ts b/src/modules/sync/diffEnvironments.ts index 9901c348..db20baf5 100644 --- a/src/modules/sync/diffEnvironments.ts +++ b/src/modules/sync/diffEnvironments.ts @@ -2,6 +2,7 @@ import chalk from "chalk"; import { logError, logInfo, LogOptions } from "../../log.js"; import { createClient } from "../../utils/client.js"; +import { syncEntityChoices } from "./constants/entities.js"; import { diff } from "./diff.js"; import { fetchModel, transformSyncModel } from "./generateSyncModel.js"; import { createAdvancedDiffFile, printDiff } from "./printDiff.js"; @@ -86,5 +87,5 @@ export const diffEnvironmentsInternal = async (params: DiffEnvironmentsParams, c return "advanced" in params ? createAdvancedDiffFile({ ...diffModel, ...params }) - : printDiff(diffModel, params); + : printDiff(diffModel, new Set(syncEntityChoices), params); }; diff --git a/src/modules/sync/generateSyncModel.ts b/src/modules/sync/generateSyncModel.ts index 8e715216..2b244f54 100644 --- a/src/modules/sync/generateSyncModel.ts +++ b/src/modules/sync/generateSyncModel.ts @@ -18,6 +18,7 @@ import packageJson from "../../../package.json" with { type: "json" }; import { logInfo, LogOptions } from "../../log.js"; import { serializeDateForFileName } from "../../utils/files.js"; import { notNullOrUndefined } from "../../utils/typeguards.js"; +import { syncEntityChoices, SyncEntityName } from "./constants/entities.js"; import { assetFoldersFileName, collectionsFileName, @@ -38,6 +39,7 @@ import { transformSpacesModel } from "./modelTransfomers/spaceTransformers.js"; import { transformTaxonomyGroupsModel } from "./modelTransfomers/taxonomyGroups.js"; import { transformWebSpotlightModel } from "./modelTransfomers/webSpotlight.js"; import { transformWorkflowModel } from "./modelTransfomers/workflow.js"; +import { SyncEntities } from "./syncModelRun.js"; import { ContentTypeSnippetsWithUnionElements, ContentTypeWithUnionElements } from "./types/contractModels.js"; import { FileContentModel } from "./types/fileContentModel.js"; import { getRequiredIds } from "./utils/contentTypeHelpers.js"; @@ -69,24 +71,26 @@ export type EnvironmentModel = { workflows: ReadonlyArray; }; -export const fetchModel = async (client: ManagementClient): Promise => { - const contentTypes = await fetchContentTypes(client) as unknown as ContentTypeWithUnionElements[]; - const contentTypeSnippets = await fetchContentTypeSnippets( - client, - ) as unknown as ContentTypeSnippetsWithUnionElements[]; - const taxonomies = await fetchTaxonomies(client); - - const webSpotlight = await fetchWebSpotlight(client); - - const assetFolders = await fetchAssetFolders(client); - - const spaces = await fetchSpaces(client); - - const collections = await fetchCollections(client); - - const languages = await fetchLanguages(client); - - const workflows = await fetchWorkflows(client); +export const fetchModel = async ( + client: ManagementClient, + entities: ReadonlySet = new Set(syncEntityChoices), +): Promise => { + const contentTypes = entities.has("contentTypes") + ? await fetchContentTypes(client) as unknown as ContentTypeWithUnionElements[] + : []; + const contentTypeSnippets = entities.has("contentTypeSnippets") + ? await fetchContentTypeSnippets(client) as unknown as ContentTypeSnippetsWithUnionElements[] + : []; + + const taxonomies = entities.has("taxonomies") ? await fetchTaxonomies(client) : []; + const webSpotlight = entities.has("webSpotlight") + ? await fetchWebSpotlight(client) + : { enabled: false, root_type: { id: "no-fetch" } }; + const assetFolders = entities.has("assetFolders") ? await fetchAssetFolders(client) : []; + const spaces = entities.has("spaces") ? await fetchSpaces(client) : []; + const collections = entities.has("collections") ? await fetchCollections(client) : []; + const languages = entities.has("languages") ? await fetchLanguages(client) : []; + const workflows = entities.has("workflows") ? await fetchWorkflows(client) : []; const allIds = [...contentTypes, ...contentTypeSnippets].reduce<{ assetIds: Set; itemIds: Set }>( (previous, type) => { @@ -219,3 +223,19 @@ type FileContentWithMetadata = generatedFromEnvironmentId: string; }>; }>; + +export const filterModel = (model: FileContentModel, entities: SyncEntities): FileContentModel => ({ + contentTypes: filterEntities(model.contentTypes, entities.contentTypes), + contentTypeSnippets: filterEntities(model.contentTypeSnippets, entities.contentTypeSnippets), + taxonomyGroups: filterEntities(model.taxonomyGroups, entities.taxonomies), + assetFolders: filterEntities(model.assetFolders, entities.assetFolders), + collections: filterEntities(model.collections, entities.collections), + spaces: filterEntities(model.spaces, entities.spaces), + languages: filterEntities(model.languages, entities.languages), + // when turned syncning web-spotlight off, give default object - diff will find no patch operations for same objects. + webSpotlight: entities.webSpotlight ? model.webSpotlight : { enabled: false, root_type: { codename: "no-sync" } }, + workflows: filterEntities(model.workflows, entities.workflows), +}); + +const filterEntities = (arr: ReadonlyArray, filterFnc: ((e: T) => boolean) | undefined): ReadonlyArray => + filterFnc ? arr.filter(filterFnc) : []; diff --git a/src/modules/sync/printDiff.ts b/src/modules/sync/printDiff.ts index a8cc70dc..2ba198bb 100644 --- a/src/modules/sync/printDiff.ts +++ b/src/modules/sync/printDiff.ts @@ -4,6 +4,7 @@ import { dirname, resolve } from "path"; import { match } from "ts-pattern"; import { logInfo, LogOptions } from "../../log.js"; +import { SyncEntityName } from "./constants/entities.js"; import { DiffModel, DiffObject } from "./types/diffModel.js"; import { PatchOperation } from "./types/patchOperation.js"; import { @@ -15,54 +16,77 @@ import { } from "./utils/fileUtils.js"; import { DiffData, resolveHtmlTemplate } from "./utils/htmlRenderers.js"; -export const printDiff = (diffModel: DiffModel, logOptions: LogOptions) => { - logInfo(logOptions, "standard", chalk.blue.bold("ASSET FOLDERS:")); - if (diffModel.assetFolders.length) { - diffModel.assetFolders.forEach(op => printPatchOperation(op, logOptions)); - } else { - logInfo(logOptions, "standard", "No asset folders to update."); +export const printDiff = ( + diffModel: DiffModel, + entities: ReadonlySet, + logOptions: LogOptions, +) => { + if (entities.has("taxonomies")) { + logInfo(logOptions, "standard", chalk.blue.bold("TAXONOMY GROUPS:")); + printDiffEntity(diffModel.taxonomyGroups, "taxonomy groups", logOptions); } - if (diffModel.collections.length) { - diffModel.collections.forEach(op => printPatchOperation(op, logOptions)); - } else { - logInfo(logOptions, "standard", "No collections to update."); + if (entities.has("contentTypeSnippets")) { + logInfo(logOptions, "standard", chalk.blue.bold("\nCONTENT TYPE SNIPPETS:")); + printDiffEntity(diffModel.contentTypeSnippets, "content type snippets", logOptions); } - logInfo(logOptions, "standard", chalk.blue.bold("\nSPACES:")); - printDiffEntity(diffModel.spaces, "spaces", logOptions); - - logInfo(logOptions, "standard", chalk.blue.bold("\nLANGUAGES:")); - printDiffEntity(diffModel.languages, "languages", logOptions); - - logInfo(logOptions, "standard", chalk.blue.bold("\nTAXONOMY GROUPS:")); - printDiffEntity(diffModel.taxonomyGroups, "taxonomy groups", logOptions); + if (entities.has("contentTypes")) { + logInfo(logOptions, "standard", chalk.blue.bold("\nCONTENT TYPES:")); + printDiffEntity(diffModel.contentTypes, "content types", logOptions); + } - logInfo(logOptions, "standard", chalk.blue.bold("\nCONTENT TYPE SNIPPETS:")); - printDiffEntity(diffModel.contentTypeSnippets, "content type snippets", logOptions); + if (entities.has("assetFolders")) { + logInfo(logOptions, "standard", chalk.blue.bold("\nASSET FOLDERS:")); + if (diffModel.assetFolders.length) { + diffModel.assetFolders.forEach(op => printPatchOperation(op, logOptions)); + } else { + logInfo(logOptions, "standard", "No asset folders to update."); + } + } - logInfo(logOptions, "standard", chalk.blue.bold("\nCONTENT TYPES:")); - printDiffEntity(diffModel.contentTypes, "content types", logOptions); + if (entities.has("collections")) { + logInfo(logOptions, "standard", chalk.blue.bold("\nCOLLECTIONS:")); + if (diffModel.collections.length) { + diffModel.collections.forEach(op => printPatchOperation(op, logOptions)); + } else { + logInfo(logOptions, "standard", "No collections to update."); + } + } - logInfo(logOptions, "standard", chalk.blue.bold("\nWORKFLOWS:")); - printDiffEntity(diffModel.workflows, "workflows", logOptions); + if (entities.has("spaces")) { + logInfo(logOptions, "standard", chalk.blue.bold("\nSPACES:")); + printDiffEntity(diffModel.spaces, "spaces", logOptions); + } - if (diffModel.webSpotlight.change !== "none") { - logInfo(logOptions, "standard", chalk.blue.bold("\nWEB SPOTLIGHT:")); + if (entities.has("languages")) { + logInfo(logOptions, "standard", chalk.blue.bold("\nLANGUAGES:")); + printDiffEntity(diffModel.languages, "languages", logOptions); + } - const messsage = match(diffModel.webSpotlight) - .with( - { change: "activate" }, - ({ rootTypeCodename }) => `Web Spotlight is to be activated with root type: ${chalk.green(rootTypeCodename)}`, - ) - .with( - { change: "changeRootType" }, - ({ rootTypeCodename }) => `Web Spotlight root type is changed to: ${chalk.green(rootTypeCodename)}`, - ) - .with({ change: "deactivate" }, () => "Web Spotlight is to be deactivated") - .exhaustive(); + if (entities.has("workflows")) { + logInfo(logOptions, "standard", chalk.blue.bold("\nWORKFLOWS:")); + printDiffEntity(diffModel.workflows, "workflows", logOptions); + } - logInfo(logOptions, "standard", messsage); + if (entities.has("webSpotlight")) { + if (diffModel.webSpotlight.change !== "none") { + logInfo(logOptions, "standard", chalk.blue.bold("\nWEB SPOTLIGHT:")); + + const messsage = match(diffModel.webSpotlight) + .with( + { change: "activate" }, + ({ rootTypeCodename }) => `Web Spotlight is to be activated with root type: ${chalk.green(rootTypeCodename)}`, + ) + .with( + { change: "changeRootType" }, + ({ rootTypeCodename }) => `Web Spotlight root type is changed to: ${chalk.green(rootTypeCodename)}`, + ) + .with({ change: "deactivate" }, () => "Web Spotlight is to be deactivated") + .exhaustive(); + + logInfo(logOptions, "standard", messsage); + } } }; diff --git a/src/modules/sync/sync.ts b/src/modules/sync/sync.ts index c5be67c8..ad110547 100644 --- a/src/modules/sync/sync.ts +++ b/src/modules/sync/sync.ts @@ -1,7 +1,7 @@ import { ManagementClient } from "@kontent-ai/management-sdk"; -import { logInfo, LogOptions } from "../../log.js"; -import { serially } from "../../utils/requests.js"; +import { LogOptions } from "../../log.js"; +import { SyncEntityName } from "./constants/entities.js"; import { syncAssetFolders } from "./sync/assetFolders.js"; import { syncAddAndReplaceCollections, syncRemoveCollections } from "./sync/collections.js"; import { syncLanguages } from "./sync/languages.js"; @@ -9,63 +9,90 @@ import { addElementsIntoSnippetsWithoutReferences, addSnippetsReferences, addSnippetsWithoutReferences, - deleteSnippet, + deleteContentTypeSnippets, updateSnippets, } from "./sync/snippets.js"; import { syncSpaces } from "./sync/spaces.js"; import { syncTaxonomies } from "./sync/taxonomy.js"; -import { addTypesWithoutReferences, deleteContentType, updateContentTypesAndAddReferences } from "./sync/types.js"; +import { addTypesWithoutReferences, deleteContentTypes, updateContentTypesAndAddReferences } from "./sync/types.js"; import { isOp } from "./sync/utils.js"; import { updateWebSpotlight } from "./sync/webSpotlight.js"; import { syncWorkflows } from "./sync/workflows.js"; import { DiffModel } from "./types/diffModel.js"; -export const sync = async (client: ManagementClient, diff: DiffModel, logOptions: LogOptions) => { +export const sync = async ( + client: ManagementClient, + diff: DiffModel, + entities: ReadonlySet, + logOptions: LogOptions, +) => { // the order of these operations is very important - await syncAssetFolders(client, diff.assetFolders, logOptions); + if (entities.has("assetFolders")) { + await syncAssetFolders(client, diff.assetFolders, logOptions); + } - logInfo(logOptions, "standard", "Syncing Collections"); - await syncAddAndReplaceCollections(client, diff.collections); + if (entities.has("collections")) { + await syncAddAndReplaceCollections(client, diff.collections, logOptions); + } - await syncSpaces(client, diff.spaces, logOptions); + if (entities.has("spaces")) { + await syncSpaces(client, diff.spaces, logOptions); + } - await syncRemoveCollections(client, diff.collections); + if (entities.has("collections")) { + await syncRemoveCollections(client, diff.collections, logOptions); + } - await syncLanguages(client, diff.languages, logOptions); + if (entities.has("languages")) { + await syncLanguages(client, diff.languages, logOptions); + } - await syncTaxonomies(client, diff.taxonomyGroups, logOptions); + if (entities.has("taxonomies")) { + await syncTaxonomies(client, diff.taxonomyGroups, logOptions); + } - logInfo(logOptions, "standard", "Adding content type snippets"); - await addSnippetsWithoutReferences(client, diff.contentTypeSnippets.added); + if (entities.has("contentTypeSnippets")) { + await addSnippetsWithoutReferences(client, diff.contentTypeSnippets.added, logOptions); + } const updateSnippetAddIntoOps = [...diff.contentTypeSnippets.updated] .map(([c, ops]) => [c, ops.filter(isOp("addInto"))] as const); - logInfo(logOptions, "standard", "Adding elements into content type snippets"); - await addElementsIntoSnippetsWithoutReferences(client, updateSnippetAddIntoOps); + if (entities.has("contentTypeSnippets")) { + await addElementsIntoSnippetsWithoutReferences(client, updateSnippetAddIntoOps, logOptions); + } - logInfo(logOptions, "standard", "Adding content types"); - await addTypesWithoutReferences(client, diff.contentTypes.added); + if (entities.has("contentTypes")) { + await addTypesWithoutReferences(client, diff.contentTypes.added, logOptions); + } - logInfo(logOptions, "standard", "Updating content type snippet's references"); - await addSnippetsReferences(client, updateSnippetAddIntoOps, diff.contentTypeSnippets.added); + if (entities.has("contentTypeSnippets")) { + await addSnippetsReferences(client, updateSnippetAddIntoOps, diff.contentTypeSnippets.added, logOptions); + } - logInfo(logOptions, "standard", "Updating content types and adding their references"); - await updateContentTypesAndAddReferences(client, diff.contentTypes); + if (entities.has("contentTypes")) { + await updateContentTypesAndAddReferences(client, diff.contentTypes, logOptions); + } - await syncWorkflows(client, diff.workflows, logOptions); + if (entities.has("workflows")) { + await syncWorkflows(client, diff.workflows, logOptions); + } // uses a created/updated type when enabling and disables before deleting the root type - logInfo(logOptions, "standard", "Updating web spotlight"); - await updateWebSpotlight(client, diff.webSpotlight); + if (entities.has("webSpotlight")) { + await updateWebSpotlight(client, diff.webSpotlight, logOptions); + } - logInfo(logOptions, "standard", "Removing content types"); - await serially(Array.from(diff.contentTypes.deleted).map(c => () => deleteContentType(client, c))); + if (entities.has("contentTypes")) { + await deleteContentTypes(client, diff.contentTypes, logOptions); + } - logInfo(logOptions, "standard", "Removing content type snippets"); - await serially(Array.from(diff.contentTypeSnippets.deleted).map(c => () => deleteSnippet(client, c))); + if (entities.has("contentTypeSnippets")) { + await deleteContentTypeSnippets(client, diff.contentTypeSnippets, logOptions); + } // replace, remove, move operations - logInfo(logOptions, "standard", "Updating content type snippets"); - await updateSnippets(client, diff.contentTypeSnippets.updated); + if (entities.has("contentTypeSnippets")) { + await updateSnippets(client, diff.contentTypeSnippets.updated, logOptions); + } }; diff --git a/src/modules/sync/sync/assetFolders.ts b/src/modules/sync/sync/assetFolders.ts index b54c91e8..6126dfb5 100644 --- a/src/modules/sync/sync/assetFolders.ts +++ b/src/modules/sync/sync/assetFolders.ts @@ -15,12 +15,13 @@ export const syncAssetFolders = async ( operations: DiffModel["assetFolders"], logOptions: LogOptions, ) => { - logInfo(logOptions, "standard", "Updating asset folders"); - if (!operations.length) { + logInfo(logOptions, "standard", "No asset folders updates"); return; } + logInfo(logOptions, "standard", "Updating asset folders"); + const removeOps = operations.filter(isOp("remove")); await serially(removeOps.map(operation => () => diff --git a/src/modules/sync/sync/collections.ts b/src/modules/sync/sync/collections.ts index 975f919e..7e3b6641 100644 --- a/src/modules/sync/sync/collections.ts +++ b/src/modules/sync/sync/collections.ts @@ -1,28 +1,45 @@ import { CollectionModels, ManagementClient } from "@kontent-ai/management-sdk"; +import { logInfo, LogOptions } from "../../../log.js"; import { omit } from "../../../utils/object.js"; import { DiffModel } from "../types/diffModel.js"; import { getTargetCodename, PatchOperation } from "../types/patchOperation.js"; -export const syncAddAndReplaceCollections = (client: ManagementClient, collections: DiffModel["collections"]) => { +export const syncAddAndReplaceCollections = ( + client: ManagementClient, + collections: DiffModel["collections"], + logOptions: LogOptions, +) => { if (!collections.length) { + logInfo(logOptions, "standard", "No collections to add or update"); return Promise.resolve(); } + logInfo(logOptions, "standard", "Adding and updating collections"); + return client .setCollections() .withData(collections.filter(op => op.op !== "remove").map(transformCollectionsReferences)) .toPromise(); }; -export const syncRemoveCollections = (client: ManagementClient, collections: DiffModel["collections"]) => { - if (!collections.length) { +export const syncRemoveCollections = ( + client: ManagementClient, + collections: DiffModel["collections"], + logOptions: LogOptions, +) => { + const collectionsRemoveOps = collections.filter(op => op.op === "remove"); + + if (!collectionsRemoveOps.length) { + logInfo(logOptions, "standard", "No collections to delete"); return Promise.resolve(); } + logInfo(logOptions, "standard", "Deleting collections"); + return client .setCollections() - .withData(collections.filter(op => op.op === "remove").map(transformCollectionsReferences)) + .withData(collectionsRemoveOps.map(transformCollectionsReferences)) .toPromise(); }; diff --git a/src/modules/sync/sync/languages.ts b/src/modules/sync/sync/languages.ts index 1a20a1a7..12448a54 100644 --- a/src/modules/sync/sync/languages.ts +++ b/src/modules/sync/sync/languages.ts @@ -14,30 +14,40 @@ export const syncLanguages = async ( operations: DiffModel["languages"], logOptions: LogOptions, ) => { - logInfo(logOptions, "standard", "Syncing Languages"); - - logInfo(logOptions, "standard", "Adding Languages"); - - await serially(operations.added.filter(op => !op.is_default).map(l => () => addLanguage(client, l))); - - logInfo(logOptions, "standard", "Updating Languages"); - const transformedOperations = [...operations.updated].map( - ([codename, operations]) => [codename, operations.map(transformLanguagePatchOperation)] as const, - ); - - const sortedOperations = transformedOperations.toSorted(([, operations], [, operations2]) => - operationsToOrdNumb(operations) - operationsToOrdNumb(operations2) - ); - - await serially( - sortedOperations.map(([codename, operations]) => () => modifyLanguage(client, codename, operations)), - ); - - logInfo(logOptions, "standard", "Deactivating languages"); - - await serially( - [...operations.deleted].map(codename => () => deleteLanguage(client, codename)), - ); + if (operations.added.length) { + logInfo(logOptions, "standard", "Adding languages"); + await serially(operations.added.filter(op => !op.is_default).map(l => () => addLanguage(client, l))); + } else { + logInfo(logOptions, "standard", "No languages to add"); + } + + if ([...operations.updated].flatMap(([, arr]) => arr).length) { + logInfo(logOptions, "standard", "Updating Languages"); + + const transformedOperations = [...operations.updated].map( + ([codename, operations]) => [codename, operations.map(transformLanguagePatchOperation)] as const, + ); + + const sortedOperations = transformedOperations.toSorted(([, operations], [, operations2]) => + operationsToOrdNumb(operations) - operationsToOrdNumb(operations2) + ); + + await serially( + sortedOperations.map(([codename, operations]) => () => modifyLanguage(client, codename, operations)), + ); + } else { + logInfo(logOptions, "standard", "No languages to update"); + } + + if (operations.deleted.size) { + logInfo(logOptions, "standard", "Deactivating languages"); + + await serially( + [...operations.deleted].map(codename => () => deleteLanguage(client, codename)), + ); + } else { + logInfo(logOptions, "standard", "No languages to deactivate"); + } }; const addLanguage = (client: ManagementClient, langauge: LanguageModels.IAddLanguageData) => diff --git a/src/modules/sync/sync/snippets.ts b/src/modules/sync/sync/snippets.ts index 531e9952..21ab66aa 100644 --- a/src/modules/sync/sync/snippets.ts +++ b/src/modules/sync/sync/snippets.ts @@ -1,5 +1,6 @@ import { ContentTypeElements, ContentTypeSnippetModels, ManagementClient } from "@kontent-ai/management-sdk"; +import { logInfo, LogOptions } from "../../../log.js"; import { omit } from "../../../utils/object.js"; import { serially } from "../../../utils/requests.js"; import { elementTypes } from "../constants/elements.js"; @@ -17,7 +18,13 @@ import { export const addSnippetsWithoutReferences = async ( client: ManagementClient, addSnippets: DiffModel["contentTypeSnippets"]["added"], + logOptions: LogOptions, ) => { + if (!addSnippets.length) { + logInfo(logOptions, "standard", "No content type snippets to add"); + return; + } + logInfo(logOptions, "standard", "Adding content type snippets"); const addSnippetsWithoutReferences = addSnippets.map(removeReferencesFromAddOp); await serially(addSnippetsWithoutReferences.map(s => () => addSnippet(client, s))); }; @@ -28,6 +35,7 @@ export const addSnippetsReferences = async ( Readonly<[string, ReadonlyArray>]> >, addSnippets: DiffModel["contentTypeSnippets"]["added"], + logOptions: LogOptions, ) => { const snippetReplaceOpsAddIntoReferencingElements = updateSnippetAddIntoOps.map(( [codename, ops], @@ -43,6 +51,12 @@ export const addSnippetsReferences = async ( ); const snippetsReplaceReferencesOps = addSnippets.map(createUpdateReferencesOps); + if (snippetsReplaceReferencesOps.every(([, arr]) => !arr.length)) { + logInfo(logOptions, "standard", "No content type snippet's references to update"); + return; + } + + logInfo(logOptions, "standard", "Updating content type snippet's references"); await serially( [...snippetReplaceOpsAddIntoReferencingElements, ...snippetsReplaceReferencesOps].map( ([codename, operations]) => () => @@ -62,6 +76,7 @@ export const addElementsIntoSnippetsWithoutReferences = async ( updateSnippetAddIntoOps: ReadonlyArray< Readonly<[string, ReadonlyArray>]> >, + logOptions: LogOptions, ) => { const addSnippetsOpsWithoutRefs = updateSnippetAddIntoOps.map(( [c, ops], @@ -79,6 +94,12 @@ export const addElementsIntoSnippetsWithoutReferences = async ( ] as const ); + if (addSnippetsOpsWithoutRefs.every(([, ops]) => !ops.length)) { + logInfo(logOptions, "standard", "No elements to add into content type snippets"); + return; + } + + logInfo(logOptions, "standard", "Adding elements into content type snippets"); await serially(addSnippetsOpsWithoutRefs.map( ([codename, operations]) => () => operations.length @@ -90,9 +111,17 @@ export const addElementsIntoSnippetsWithoutReferences = async ( export const updateSnippets = async ( client: ManagementClient, updateSnippetsOps: DiffModel["contentTypeSnippets"]["updated"], + logOptions: LogOptions, ) => { const otherSnippetOps = [...updateSnippetsOps.entries()] .map(([c, ops]) => [c, ops.filter(o => !isOp("addInto")(o))] as const); + + if (otherSnippetOps.flatMap(([, ops]) => ops).length === 0) { + logInfo(logOptions, "standard", "No content type snippets to update"); + return; + } + + logInfo(logOptions, "standard", "Updating content type snippets"); await serially( otherSnippetOps.map( ([codename, operations]) => () => @@ -107,6 +136,19 @@ export const updateSnippets = async ( ); }; +export const deleteContentTypeSnippets = async ( + client: ManagementClient, + snippetOps: DiffModel["contentTypeSnippets"], + logOptions: LogOptions, +) => { + if (snippetOps.deleted.size) { + logInfo(logOptions, "standard", "Deleting content type snippets"); + await serially(Array.from(snippetOps.deleted).map(c => () => deleteSnippet(client, c))); + } else { + logInfo(logOptions, "standard", "No content type snippets to delete"); + } +}; + const addSnippet = (client: ManagementClient, snippet: ContentTypeSnippetModels.IAddContentTypeSnippetData) => client .addContentTypeSnippet() @@ -124,7 +166,7 @@ const updateSnippet = ( .withData(snippetData) .toPromise(); -export const deleteSnippet = ( +const deleteSnippet = ( client: ManagementClient, codename: string, ) => diff --git a/src/modules/sync/sync/spaces.ts b/src/modules/sync/sync/spaces.ts index 4ee30e02..3c06bef3 100644 --- a/src/modules/sync/sync/spaces.ts +++ b/src/modules/sync/sync/spaces.ts @@ -12,33 +12,46 @@ export const syncSpaces = async ( model: DiffModel["spaces"], logOptions: LogOptions, ) => { - logInfo(logOptions, "standard", "Adding new spaces"); - - await serially(model.added.map(space => () => - client - .addSpace() - .withData(space as SpaceModels.IAddSpaceData) - .toPromise() - )); - - logInfo(logOptions, "standard", "Updating spaces"); - - await serially([...model.updated].map(([spaceCodename, operations]) => () => - client - .modifySpace() - .bySpaceCodename(spaceCodename) - .withData(operations.map(convertOperation)) - .toPromise() - )); - - logInfo(logOptions, "standard", "Removing spaces"); - - await serially([...model.deleted].map(spaceCodename => () => - client - .deleteSpace() - .bySpaceCodename(spaceCodename) - .toPromise() - )); + if (model.added.length) { + logInfo(logOptions, "standard", "Adding spaces"); + + await serially(model.added.map(space => () => + client + .addSpace() + .withData(space as SpaceModels.IAddSpaceData) + .toPromise() + )); + } else { + logInfo(logOptions, "standard", "No spaces to add"); + } + + if ([...model.updated].flatMap(([, arr]) => arr).length) { + logInfo(logOptions, "standard", "Updating spaces"); + + await serially([...model.updated].map(([spaceCodename, operations]) => () => + client + .modifySpace() + .bySpaceCodename(spaceCodename) + .withData(operations.map(convertOperation)) + .toPromise() + )); + } else { + logInfo(logOptions, "standard", "No spaces to update"); + } + + if (model.deleted.size) { + logInfo(logOptions, "standard", "Deleting spaces"); + + await serially([...model.deleted].map(spaceCodename => () => + client + .deleteSpace() + .bySpaceCodename(spaceCodename) + .toPromise() + )); + } + { + logInfo(logOptions, "standard", "No spaces to delete"); + } }; const convertOperation = (operation: PatchOperation): SpaceModels.IModifySpaceData => diff --git a/src/modules/sync/sync/taxonomy.ts b/src/modules/sync/sync/taxonomy.ts index 7368f7d3..d87350cf 100644 --- a/src/modules/sync/sync/taxonomy.ts +++ b/src/modules/sync/sync/taxonomy.ts @@ -10,24 +10,36 @@ export const syncTaxonomies = async ( taxonomies: DiffModel["taxonomyGroups"], logOptions: LogOptions, ) => { - logInfo(logOptions, "standard", "Adding taxonomies"); - await serially(taxonomies.added.map(g => () => addTaxonomyGroup(client, g))); + if (taxonomies.added.length) { + logInfo(logOptions, "standard", "Adding taxonomies"); + await serially(taxonomies.added.map(g => () => addTaxonomyGroup(client, g))); + } else { + logInfo(logOptions, "standard", "No taxonomies to add"); + } - logInfo(logOptions, "standard", "Updating taxonomies"); - await serially( - Array.from(taxonomies.updated.entries()).map(([codename, operations]) => () => - operations.length - ? updateTaxonomyGroup( - client, - codename, - operations.map(transformTaxonomyOperations), - ) - : Promise.resolve() - ), - ); + if ([...taxonomies.updated].flatMap(([, arr]) => arr).length) { + logInfo(logOptions, "standard", "Updating taxonomies"); + await serially( + Array.from(taxonomies.updated.entries()).map(([codename, operations]) => () => + operations.length + ? updateTaxonomyGroup( + client, + codename, + operations.map(transformTaxonomyOperations), + ) + : Promise.resolve() + ), + ); + } else { + logInfo(logOptions, "standard", "No taxonomies to update"); + } - logInfo(logOptions, "standard", "Deleting taxonomies"); - await serially(Array.from(taxonomies.deleted).map(c => () => deleteTaxonomyGroup(client, c))); + if (taxonomies.deleted.size) { + logInfo(logOptions, "standard", "Deleting taxonomies"); + await serially(Array.from(taxonomies.deleted).map(c => () => deleteTaxonomyGroup(client, c))); + } else { + logInfo(logOptions, "standard", "No taxonomies to delete"); + } }; const addTaxonomyGroup = (client: ManagementClient, taxonomy: TaxonomyModels.IAddTaxonomyRequestModel) => diff --git a/src/modules/sync/sync/types.ts b/src/modules/sync/sync/types.ts index b9276630..33f23e1f 100644 --- a/src/modules/sync/sync/types.ts +++ b/src/modules/sync/sync/types.ts @@ -1,5 +1,6 @@ import { ContentTypeModels, ManagementClient } from "@kontent-ai/management-sdk"; +import { logInfo, LogOptions } from "../../../log.js"; import { omit } from "../../../utils/object.js"; import { serially } from "../../../utils/requests.js"; import { DiffModel } from "../types/diffModel.js"; @@ -8,17 +9,32 @@ import { createUpdateReferencesOps, removeReferencesFromAddOp } from "./utils.js export const addTypesWithoutReferences = async ( client: ManagementClient, addContentTypes: DiffModel["contentTypes"]["added"], + logOptions: LogOptions, ) => { const addTypesWithoutReferences = addContentTypes.map(removeReferencesFromAddOp); + + if (!addTypesWithoutReferences.length) { + logInfo(logOptions, "standard", "No content types to add"); + return; + } + + logInfo(logOptions, "standard", "Adding content types"); await serially(addTypesWithoutReferences.map(t => () => addContentType(client, t))); }; export const updateContentTypesAndAddReferences = async ( client: ManagementClient, typeOps: DiffModel["contentTypes"], + logOptions: LogOptions, ) => { const typesReplaceReferencesOps = typeOps.added.map(createUpdateReferencesOps); + if (!typesReplaceReferencesOps.length) { + logInfo(logOptions, "standard", "No content types to update"); + return; + } + + logInfo(logOptions, "standard", "Updating content types and adding their references"); await serially( [...typeOps.updated.entries(), ...typesReplaceReferencesOps].map(([codename, operations]) => () => operations.length @@ -36,6 +52,19 @@ export const updateContentTypesAndAddReferences = async ( ); }; +export const deleteContentTypes = async ( + client: ManagementClient, + typeOps: DiffModel["contentTypes"], + logOptions: LogOptions, +) => { + if (typeOps.deleted.size) { + logInfo(logOptions, "standard", "Deleting content types"); + await serially(Array.from(typeOps.deleted).map(c => () => deleteContentType(client, c))); + } else { + logInfo(logOptions, "standard", "No content types to delete"); + } +}; + const addContentType = (client: ManagementClient, type: ContentTypeModels.IAddContentTypeData) => client .addContentType() @@ -53,7 +82,7 @@ const updateContentType = ( .withData(typeData) .toPromise(); -export const deleteContentType = ( +const deleteContentType = ( client: ManagementClient, codename: string, ) => diff --git a/src/modules/sync/sync/webSpotlight.ts b/src/modules/sync/sync/webSpotlight.ts index b95fb3a8..b22302f6 100644 --- a/src/modules/sync/sync/webSpotlight.ts +++ b/src/modules/sync/sync/webSpotlight.ts @@ -1,14 +1,28 @@ import { ManagementClient } from "@kontent-ai/management-sdk"; import { match, P } from "ts-pattern"; +import { logInfo, LogOptions } from "../../../log.js"; import { WebSpotlightDiffModel } from "../types/diffModel.js"; -export const updateWebSpotlight = (client: ManagementClient, diffModel: WebSpotlightDiffModel): Promise => +export const updateWebSpotlight = ( + client: ManagementClient, + diffModel: WebSpotlightDiffModel, + logOptions: LogOptions, +): Promise => match(diffModel) - .with({ change: "none" }, () => Promise.resolve()) - .with({ change: "deactivate" }, () => client.deactivateWebSpotlight().toPromise()) + .with({ change: "none" }, () => { + logInfo(logOptions, "standard", "No web spotlight changes to perform"); + return Promise.resolve(); + }) + .with({ change: "deactivate" }, () => { + logInfo(logOptions, "standard", "Deactivating web spotlight"); + return client.deactivateWebSpotlight().toPromise(); + }) .with( { change: P.union("activate", "changeRootType") }, - ws => client.activateWebSpotlight().withData({ root_type: { codename: ws.rootTypeCodename } }).toPromise(), + ws => { + logInfo(logOptions, "standard", "Updating web spotlight"); + return client.activateWebSpotlight().withData({ root_type: { codename: ws.rootTypeCodename } }).toPromise(); + }, ) .exhaustive(); diff --git a/src/modules/sync/sync/workflows.ts b/src/modules/sync/sync/workflows.ts index 74eca0d9..4c8f02bc 100644 --- a/src/modules/sync/sync/workflows.ts +++ b/src/modules/sync/sync/workflows.ts @@ -10,27 +10,38 @@ export const syncWorkflows = async ( operations: DiffModel["workflows"], logOptions: LogOptions, ) => { - logInfo(logOptions, "standard", "Adding Workflows"); - - await serially(operations.added.map(w => () => addWorkflow(client, w))); - - logInfo(logOptions, "standard", "Updating Workflows"); - - await serially( - [...operations.updated.keys()].map(codename => () => - modifyWorkflow( - client, - codename, - operations.sourceWorkflows.find(w => w.codename === codename) - ?? throwError(`Workflow { codename: ${codename} } not found.`), - ) - ), - ); - - logInfo(logOptions, "standard", "Deleting Workflows"); - await serially( - [...operations.deleted].map(codename => () => deleteWorkflow(client, codename)), - ); + if (operations.added.length) { + logInfo(logOptions, "standard", "Adding workflows"); + await serially(operations.added.map(w => () => addWorkflow(client, w))); + } else { + logInfo(logOptions, "standard", "No workflows to add"); + } + + if ([...operations.updated].flatMap(([, arr]) => arr).length) { + logInfo(logOptions, "standard", "Updating workflows"); + + await serially( + [...operations.updated.keys()].map(codename => () => + modifyWorkflow( + client, + codename, + operations.sourceWorkflows.find(w => w.codename === codename) + ?? throwError(`Workflow { codename: ${codename} } not found.`), + ) + ), + ); + } else { + logInfo(logOptions, "standard", "No workflows to update"); + } + + if (operations.deleted.size) { + logInfo(logOptions, "standard", "Deleting workflows"); + await serially( + [...operations.deleted].map(codename => () => deleteWorkflow(client, codename)), + ); + } else { + logInfo(logOptions, "standard", "No workflows to delete"); + } }; const addWorkflow = (client: ManagementClient, workflow: WorkflowModels.IAddWorkflowData) => diff --git a/src/modules/sync/syncModelRun.ts b/src/modules/sync/syncModelRun.ts index 04a35317..f3c20140 100644 --- a/src/modules/sync/syncModelRun.ts +++ b/src/modules/sync/syncModelRun.ts @@ -1,11 +1,23 @@ import { ManagementClient } from "@kontent-ai/management-sdk"; -import { logError, LogOptions } from "../../log.js"; +import { logError, logInfo, LogOptions } from "../../log.js"; import { createClient } from "../../utils/client.js"; +import { Expect } from "../../utils/types.js"; +import { syncEntityDependencies, SyncEntityName } from "./constants/entities.js"; import { diff } from "./diff.js"; -import { fetchModel, transformSyncModel } from "./generateSyncModel.js"; +import { fetchModel, filterModel, transformSyncModel } from "./generateSyncModel.js"; import { sync } from "./sync.js"; import { DiffModel } from "./types/diffModel.js"; +import { + AssetFolderSyncModel, + CollectionSyncModel, + ContentTypeSnippetsSyncModel, + ContentTypeSyncModel, + LanguageSyncModel, + SpaceSyncModel, + TaxonomySyncModel, + WorkflowSyncModel, +} from "./types/syncModel.js"; import { getSourceItemAndAssetCodenames, getTargetContentModel, @@ -13,10 +25,27 @@ import { } from "./utils/getContentModel.js"; import { validateDiffedModel, validateSyncModelFolder } from "./validation.js"; +type ExpectedSyncEntities = Record boolean) | boolean>; + +export type SyncEntities = Partial< + Expect boolean; + contentTypeSnippets: (snippet: ContentTypeSnippetsSyncModel) => boolean; + taxonomies: (taxonomy: TaxonomySyncModel) => boolean; + assetFolders: (assetFolder: AssetFolderSyncModel) => boolean; + collections: (collection: CollectionSyncModel) => boolean; + spaces: (space: SpaceSyncModel) => boolean; + languages: (language: LanguageSyncModel) => boolean; + workflows: (workflow: WorkflowSyncModel) => boolean; + webSpotlight: boolean; + }> +>; + export type SyncModelRunParams = Readonly< & { targetEnvironmentId: string; targetApiKey: string; + entities: SyncEntities; } & ( | { folderName: string } @@ -24,9 +53,31 @@ export type SyncModelRunParams = Readonly< ) & LogOptions >; +/** + * Synchronizes content model between two environments. This function can either synchronize + * from a source environment to a target environment or use a pre-defined folder containing the content model + * for synchronization. + * + * Warning!: Synchronizing workflows will make them accessible to all roles in your environment. + * + * @param {SyncModelRunParams} params - The parameters for running the synchronization. + * @param {string} params.targetEnvironmentId - The ID of the target environment where the content model will be synchronized. + * @param {string} params.targetApiKey - The API key for accessing the target environment. + * @param {SyncEntities} params.entities - The entities that need to be synchronized. It includes content types, snippets, taxonomies, etc. If entity is not specified, no items from the given entity will be synced. To sync all item form an entity use () => true. + * @param {string} [params.folderName] - Optional. The name of the folder containing the source content model to be synchronized. + * @param {string} [params.sourceEnvironmentId] - Optional. The ID of the source environment from which the content model will be fetched. + * @param {string} [params.sourceApiKey] - Optional. The API key for accessing the source environment. + * @param {LogOptions} params.logOptions - Optional. Configuration for logging options such as log level, output, etc. + * + * @returns {Promise} A promise that resolves when the synchronization is complete. + */ +export const syncModelRun = (params: SyncModelRunParams) => syncModelRunInternal(params, "sync-model-run-API"); -export const syncModelRun = async (params: SyncModelRunParams) => { - const commandName = "sync-model-run-API"; +export const syncModelRunInternal = async ( + params: SyncModelRunParams, + commandName: string, + withDiffModel: (diffModel: DiffModel) => Promise = () => Promise.resolve(), +) => { const targetEnvironmentClient = createClient({ apiKey: params.targetApiKey, environmentId: params.targetEnvironmentId, @@ -35,16 +86,25 @@ export const syncModelRun = async (params: SyncModelRunParams) => { const diffModel = await getDiffModel(params, targetEnvironmentClient, commandName); - await validateTargetEnvironment(diffModel, targetEnvironmentClient); + logInfo(params, "standard", "Validating patch operations...\n"); + + try { + await validateTargetEnvironment(diffModel, targetEnvironmentClient); + } catch (e) { + throw new Error(JSON.stringify(e, Object.getOwnPropertyNames(e))); + } + + await withDiffModel(diffModel); await sync( targetEnvironmentClient, diffModel, + new Set(Object.keys(params.entities)) as ReadonlySet, params, ); }; -export const getDiffModel = async ( +const getDiffModel = async ( params: SyncModelRunParams, targetClient: ManagementClient, commandName: string, @@ -56,6 +116,10 @@ export const getDiffModel = async ( } } + const fetchDependencies = new Set( + Object.keys(params.entities).flatMap(e => syncEntityDependencies[e as SyncEntityName]), + ); + const sourceModel = "folderName" in params ? await readContentModelFromFolder(params.folderName).catch(e => { if (e instanceof AggregateError) { @@ -66,11 +130,14 @@ export const getDiffModel = async ( process.exit(1); }) : transformSyncModel( - await fetchModel(createClient({ - environmentId: params.sourceEnvironmentId, - apiKey: params.sourceApiKey, - commandName, - })), + await fetchModel( + createClient({ + environmentId: params.sourceEnvironmentId, + apiKey: params.sourceApiKey, + commandName, + }), + fetchDependencies, + ), params, ); @@ -80,13 +147,17 @@ export const getDiffModel = async ( targetClient, allCodenames, params, + fetchDependencies, ); + const filteredSourceModel = filterModel(sourceModel, params.entities); + const filteredTargetModel = filterModel(transformedTargetModel, params.entities); + return diff({ targetAssetsReferencedFromSourceByCodenames: assetsReferences, targetItemsReferencedFromSourceByCodenames: itemReferences, - targetEnvModel: transformedTargetModel, - sourceEnvModel: sourceModel, + targetEnvModel: filteredTargetModel, + sourceEnvModel: filteredSourceModel, }); }; diff --git a/src/modules/sync/utils/getContentModel.ts b/src/modules/sync/utils/getContentModel.ts index 5932131d..eab3979f 100644 --- a/src/modules/sync/utils/getContentModel.ts +++ b/src/modules/sync/utils/getContentModel.ts @@ -8,6 +8,7 @@ import { throwError } from "../../../utils/error.js"; import { second } from "../../../utils/function.js"; import { superiorFromEntries } from "../../../utils/object.js"; import { notNullOrUndefined } from "../../../utils/typeguards.js"; +import { syncEntityChoices, SyncEntityName } from "../constants/entities.js"; import { assetFoldersFileName, collectionsFileName, @@ -109,8 +110,9 @@ export const getTargetContentModel = async ( targetClient: ManagementClient, itemAndAssetCodenames: AssetItemsCodenames, logOptions: LogOptions, + entities: Set = new Set(syncEntityChoices), ) => { - const targetModel = await fetchModel(targetClient); + const targetModel = await fetchModel(targetClient, entities); const targetAssetsBySourceCodenames = await fetchRequiredAssetsByCodename( targetClient, Array.from(itemAndAssetCodenames.assetCodenames), diff --git a/src/public.ts b/src/public.ts index 5a49eb37..adfe1c39 100644 --- a/src/public.ts +++ b/src/public.ts @@ -9,7 +9,19 @@ export { RunMigrationFilterParams, runMigrations, RunMigrationsParams } from "./ export { diffEnvironments, DiffEnvironmentsParams } from "./modules/sync/diffEnvironments.js"; export { syncModelExport, SyncModelExportParams } from "./modules/sync/syncModelExport.js"; -export { syncModelRun, SyncModelRunParams } from "./modules/sync/syncModelRun.js"; +export { SyncEntities, syncModelRun, SyncModelRunParams } from "./modules/sync/syncModelRun.js"; +export { + AssetFolderSyncModel, + CollectionSyncModel, + ContentTypeSnippetsSyncModel, + ContentTypeSyncModel, + LanguageSyncModel, + SpaceSyncModel, + SyncSnippetElement, + SyncTypeElement, + TaxonomySyncModel, + WebSpotlightSyncModel, +} from "./modules/sync/types/syncModel.js"; export { syncContentExport, SyncContentExportParams } from "./modules/syncContent/syncContentExport.js"; export { SyncContentFilterParams, syncContentRun, SyncContentRunParams } from "./modules/syncContent/syncContentRun.js"; diff --git a/src/utils/types.ts b/src/utils/types.ts index 91316fab..c0eac343 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -69,6 +69,8 @@ export type AnyOnePropertyOf = [keyof Obj, keyof Obj] extend : never : never; +export type Expect = U; + /** * Adds a new property type to each object in a tuple of objects. * diff --git a/tests/integration/importExport/utils/compare.ts b/tests/integration/importExport/utils/compare.ts index c0eb9443..c38cda0c 100644 --- a/tests/integration/importExport/utils/compare.ts +++ b/tests/integration/importExport/utils/compare.ts @@ -87,6 +87,53 @@ export const expectSameAllEnvData = ( /* eslint-enable @typescript-eslint/no-unused-expressions */ }; +export const expectDifferentAllEnvData = ( + data1: AllEnvData, + data2: AllEnvData, + filterParam: FilterParam = { exclude: [] }, +) => { + const has = (e: keyof AllEnvData) => + "exclude" in filterParam + ? !filterParam.exclude.includes(e) + : filterParam.include.includes(e); + + const sortedVariants = (data: AllEnvData) => sortBy(data.variants, v => `${v.item.id};${v.language.id}`); + + // disabling unused expressions here as they are a lot more compact then ifs and a lot of them is necessary here + /* eslint-disable @typescript-eslint/no-unused-expressions */ + has("collections") + && expect(sortByCodename(data1.collections)).not.toStrictEqual(sortByCodename(data2.collections)); + has("spaces") && expect(sortByCodename(data1.spaces)).not.toStrictEqual(sortByCodename(data2.spaces)); + has("languages") && expect(sortByCodename(data1.languages)).not.toStrictEqual(sortByCodename(data2.languages)); + has("previewUrls") && expect(data1.previewUrls).not.to.toStrictEqual(data2.previewUrls); + has("taxonomies") && expect(sortByCodename(data1.taxonomies)).not.toStrictEqual(sortByCodename(data2.taxonomies)); + has("assetFolders") && expect(data1.assetFolders).not.toStrictEqual(data2.assetFolders); + has("assets") && expect(sortByCodename(data1.assets)).not.toStrictEqual(sortByCodename(data2.assets)); + has("roles") && expect(sortBy(data1.roles, r => r.name)).not.toStrictEqual(sortBy(data2.roles, r => r.name)); + has("workflows") && expect(sortByCodename(data1.workflows)).not.toStrictEqual(sortByCodename(data2.workflows)); + has("snippets") && expect(sortByCodename(data1.snippets)).not.toStrictEqual(sortByCodename(data2.snippets)); + has("types") + && expect( + sortByCodename( + data1.types.map(t => ({ + ...t, + elements: t.content_groups?.length ? sortTypesElements(t.elements) : t.elements, + })), + ), + ).not.toStrictEqual( + sortByCodename(data2.types.map(t => ({ + ...t, + elements: t.content_groups?.length ? sortTypesElements(t.elements) : t.elements, + }))), + ); + has("items") && expect(sortByCodename(data1.items)).not.toStrictEqual(sortByCodename(data2.items)); + has("variants") && expect(sortedVariants(data1)).not.toStrictEqual(sortedVariants(data2)); + has("webhooks") + && expect(sortBy(data1.webhooks, w => w.name)).not.toStrictEqual(sortBy(data2.webhooks, w => w.name)); + has("webSpotlight") && expect(data1.webSpotlight).not.toStrictEqual(data2.webSpotlight); + /* eslint-enable @typescript-eslint/no-unused-expressions */ +}; + const sortBy = (entities: ReadonlyArray, sortByPicker: (e: T) => string): ReadonlyArray => [...entities].sort((e1, e2) => sortByPicker(e1).localeCompare(sortByPicker(e2))); diff --git a/tests/integration/sync/syncModel.test.ts b/tests/integration/sync/syncModel.test.ts index b90f6935..6abeb3d0 100644 --- a/tests/integration/sync/syncModel.test.ts +++ b/tests/integration/sync/syncModel.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import * as fileNames from "../../../src/modules/sync/constants/filename.ts"; import { syncModelRun } from "../../../src/public.ts"; -import { expectSameAllEnvData, prepareReferences } from "../importExport/utils/compare.ts"; +import { expectDifferentAllEnvData, expectSameAllEnvData, prepareReferences } from "../importExport/utils/compare.ts"; import { AllEnvData, loadAllEnvData } from "../importExport/utils/envData.ts"; import { runCommand } from "../utils/runCommand.ts"; import { withTestEnvironment } from "../utils/setup.ts"; @@ -24,31 +24,37 @@ if (!SYNC_TARGET_TEST_ENVIRONMENT_ID) { throw new Error("SYNC_TARGET_TEST_ENVIRONMENT_ID env variable was not provided."); } +const allSyncEntities = [ + "types", + "snippets", + "taxonomies", + "webSpotlight", + "assetFolders", + "collections", + "languages", + "spaces", + "workflows", +] as const; + +type SyncEntityName = (typeof allSyncEntities)[number]; + const expectSameSyncEnvironments = async ( environmentId1: string, environmentId2: string, + include: ReadonlyArray = allSyncEntities, ): Promise => { - const syncEntities = [ - "types", - "snippets", - "taxonomies", - "webSpotlight", - "assetFolders", - "collections", - "languages", - "workflows", - ] as const; - - const data1 = await loadAllEnvData(environmentId1, { include: syncEntities }) + const coInclude = allSyncEntities.filter(e => !include.includes(e) && e !== "webSpotlight"); + const data1 = await loadAllEnvData(environmentId1, { include: allSyncEntities }) .then(prepareReferences) .then(sortAssetFolders) .then(prepareLanguages); - const data2 = await loadAllEnvData(environmentId2, { include: syncEntities }) + const data2 = await loadAllEnvData(environmentId2, { include: allSyncEntities }) .then(prepareReferences) .then(sortAssetFolders) .then(prepareLanguages); - expectSameAllEnvData(data1, data2, { include: syncEntities }); + expectSameAllEnvData(data1, data2, { include }); + expectDifferentAllEnvData(data1, data2, { include: coInclude }); }; const sortAssetFolders = (allData: AllEnvData): AllEnvData => ({ @@ -66,7 +72,7 @@ describe.concurrent("Sync model of two environments with credentials", () => { "Sync source environment to target environment directly from source environment", withTestEnvironment(SYNC_TARGET_TEST_ENVIRONMENT_ID, async (environmentId) => { const command = - `sync-model run -s=${SYNC_SOURCE_TEST_ENVIRONMENT_ID} --sk=${API_KEY} -t=${environmentId} --tk=${API_KEY} --verbose --skipConfirmation`; + `sync-model run -s=${SYNC_SOURCE_TEST_ENVIRONMENT_ID} --sk=${API_KEY} -t=${environmentId} --tk=${API_KEY} --entities=contentTypes contentTypeSnippets taxonomies webSpotlight assetFolders collections spaces languages workflows --verbose --skipConfirmation`; await runCommand(command); @@ -74,6 +80,22 @@ describe.concurrent("Sync model of two environments with credentials", () => { }), ); + it.concurrent( + "Sync source environment to target environment directly from source environment with include core entities", + withTestEnvironment(SYNC_TARGET_TEST_ENVIRONMENT_ID, async (environmentId) => { + const command = + `sync-model run -s=${SYNC_SOURCE_TEST_ENVIRONMENT_ID} --sk=${API_KEY} -t=${environmentId} --tk=${API_KEY} --entities contentTypes contentTypeSnippets taxonomies --verbose --skipConfirmation`; + + await runCommand(command); + + await expectSameSyncEnvironments(environmentId, SYNC_SOURCE_TEST_ENVIRONMENT_ID, [ + "types", + "snippets", + "taxonomies", + ]); + }), + ); + it.concurrent( "Sync target environment to source environment directly from target environment using API", withTestEnvironment(SYNC_SOURCE_TEST_ENVIRONMENT_ID, async (environmentId) => { @@ -83,11 +105,40 @@ describe.concurrent("Sync model of two environments with credentials", () => { targetEnvironmentId: environmentId, targetApiKey: API_KEY, verbose: true, + entities: { + contentTypes: () => true, + contentTypeSnippets: () => true, + taxonomies: () => true, + collections: () => true, + assetFolders: () => true, + spaces: () => true, + languages: () => true, + workflows: () => true, + webSpotlight: true, + }, }); await expectSameSyncEnvironments(environmentId, SYNC_TARGET_TEST_ENVIRONMENT_ID); }), ); + + it.concurrent( + "Sync target environment languages to source environment directly from target environment using API", + withTestEnvironment(SYNC_SOURCE_TEST_ENVIRONMENT_ID, async (environmentId) => { + await syncModelRun({ + sourceEnvironmentId: SYNC_TARGET_TEST_ENVIRONMENT_ID, + sourceApiKey: API_KEY, + targetEnvironmentId: environmentId, + targetApiKey: API_KEY, + verbose: true, + entities: { + languages: () => true, + }, + }); + + await expectSameSyncEnvironments(environmentId, SYNC_TARGET_TEST_ENVIRONMENT_ID, ["languages"]); + }), + ); }); describe.concurrent("Sync environment from folder", () => { @@ -122,7 +173,7 @@ describe.concurrent("Sync environment from folder", () => { "Sync environment from folder", withTestEnvironment(SYNC_TARGET_TEST_ENVIRONMENT_ID, async (environmentId) => { const command = - `sync-model run -t=${environmentId} --tk=${API_KEY} -f=${folderPath} --verbose --skipConfirmation`; + `sync-model run -t=${environmentId} --tk=${API_KEY} -f=${folderPath} --entities=contentTypes contentTypeSnippets taxonomies webSpotlight assetFolders collections spaces languages workflows --verbose --skipConfirmation`; await runCommand(command);