Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions api/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,24 @@ export const STEPPER_STEPS: any = {
TESTING: 4,
MIGRATION: 5,
};

// Delta migration (iteration > 1) inserts a "Map Entry" step after Content Mapping, shifting
// Testing and Migration down by one. Iteration 1 keeps the original 5-step numbering.
export const DELTA_STEPPER_STEPS: any = {
LEGACY_CMS: 1,
DESTINATION_STACK: 2,
CONTENT_MAPPING: 3,
MAP_ENTRY: 4,
TESTING: 5,
MIGRATION: 6,
};

/**
* Returns the step-number map for a given iteration: the 6-step delta layout (with Map Entry)
* from iteration 2 onwards, or the original 5-step layout for iteration 1.
*/
export const getStepperSteps = (iteration?: number) =>
(iteration ?? 1) > 1 ? DELTA_STEPPER_STEPS : STEPPER_STEPS;
export const PREDEFINED_STATUS = [
'Draft',
'Ready',
Expand Down
72 changes: 72 additions & 0 deletions api/src/services/contentMapper.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,39 @@ const putTestData = async (req: Request) => {
}
};

/**
* Splits the current iteration's content types into "new" vs "old" relative to the
* previous iteration, by comparing on `otherCmsUid`.
* - mode 'new': content types NOT present in the previous iteration (mapped for the first time).
* - mode 'old': content types already present in the previous iteration (already migrated) AND
* that have at least one entry mapping β€” Step 4 (Map Entry) can only map entries for content
* types that actually have entries, so types with an empty entryMapping are excluded.
* Used on delta migrations (iteration > 1) so Step 3 (Map Content Fields) only re-maps new
* types and Step 4 (Map Entry) only maps entries of already-migrated types that have entries.
* @param currentCts - Content types of the current iteration.
* @param prevCts - Content types of the previous iteration.
* @param mode - 'new' or 'old'.
* @returns The filtered subset of `currentCts`.
*/
const filterContentTypesByIteration = (
currentCts: ContentTypesMapper[],
prevCts: ContentTypesMapper[],
mode: 'new' | 'old',
): ContentTypesMapper[] => {
const prevUids = new Set(
(prevCts ?? [])
.map((ct) => ct?.otherCmsUid)
.filter((uid): uid is string => Boolean(uid)),
);
return (currentCts ?? []).filter((ct) => {
if (mode === 'old') {
const hasEntries = Array.isArray(ct?.entryMapping) && ct.entryMapping.length > 0;
return prevUids.has(ct?.otherCmsUid) && hasEntries;
}
return !prevUids.has(ct?.otherCmsUid);
});
};

/**
* Retrieves the content types based on the provided request parameters.
* @param req - The request object containing the parameters.
Expand All @@ -336,6 +369,9 @@ const getContentTypes = async (req: Request) => {
const skip: any = req?.params?.skip;
const limit: any = req?.params?.limit;
const search: string = req?.params?.searchText?.toLowerCase();
// Delta migration: 'new' (default) β†’ first-time content types for Step 3 (field mapping);
// 'old' β†’ already-migrated content types for Step 4 (entry mapping). Only applied when iteration > 1.
const filter: 'new' | 'old' = req?.query?.filter === 'old' ? 'old' : 'new';

let result: any = [];
let totalCount = 0;
Expand Down Expand Up @@ -386,6 +422,42 @@ const getContentTypes = async (req: Request) => {
`πŸ“¦ [getContentTypes] Found ${content_mapper.length} content types`,
);

// Delta migration: from iteration 2 onwards, split content types into new vs old
// relative to the previous iteration so Step 3 (field mapping) shows only new types
// and Step 4 (entry mapping) shows only already-migrated types. Iteration 1 is untouched.
if (iteration > 1) {
const PrevContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration - 1);
await PrevContentTypesMapperModelLowdb.read();
const prevContentMapper =
PrevContentTypesMapperModelLowdb.chain.get('ContentTypesMappers').value() ?? [];

const filtered = filterContentTypesByIteration(content_mapper, prevContentMapper, filter);
content_mapper.length = 0;
content_mapper.push(...filtered);

// For Step 4 (Map Entry), derive each content type's status from the entry mapper so the
// list icon reflects persisted state on load: 'Updated' (2/green) when the content type has
// at least one entry marked isUpdate, otherwise 'Mapped' (1/blue). Without this the UI would
// show every type as blue after a reload until the user opens it.
if (filter === 'old') {
const EntryMapperModelLowdb = getEntryMapperDb(projectId, iteration);
await EntryMapperModelLowdb.read();
const entryMapperItems = EntryMapperModelLowdb.chain.get('entry_mapper').value() ?? [];
const updatedContentTypeIds = new Set(
entryMapperItems
.filter((entry: any) => entry?.isUpdate)
.map((entry: any) => entry?.contentTypeId),
);
content_mapper.forEach((ct: any) => {
ct.status = updatedContentTypeIds.has(ct?.id) ? 2 : 1;
});
}

logger.info(
`πŸ“¦ [getContentTypes] iteration ${iteration}, filter '${filter}' β†’ ${content_mapper.length} content types`,
);
}

if (!isEmpty(content_mapper)) {
if (search) {
const filteredResult = content_mapper
Expand Down
6 changes: 4 additions & 2 deletions api/src/services/migration.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
HTTP_TEXTS,
HTTP_CODES,
LOCALE_MAPPER,
STEPPER_STEPS,
getStepperSteps,
CMS,
GET_AUDIT_DATA,
MIGRATION_DATA_CONFIG,
Expand Down Expand Up @@ -151,7 +151,9 @@ const createTestStack = async (req: Request): Promise<LoginServiceType> => {


ProjectModelLowdb.update((data: any) => {
data.projects[index].current_step = STEPPER_STEPS['TESTING'];
// Delta migration: Testing is step 5 on iteration 2+ (4 on iteration 1).
data.projects[index].current_step =
getStepperSteps(data.projects[index]?.iteration)['TESTING'];
data.projects[index].current_test_stack_id = res?.data?.stack?.api_key;
data.projects[index].test_stacks.push({
stackUid: res?.data?.stack?.api_key,
Expand Down
39 changes: 32 additions & 7 deletions api/src/services/projects.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
HTTP_CODES,
STEPPER_STEPS,
NEW_PROJECT_STATUS,
getStepperSteps,
} from "../constants/index.js";
import { config } from "../config/index.js";
import { getLogMessage, isEmpty, safePromise } from "../utils/index.js";
Expand Down Expand Up @@ -934,6 +935,11 @@ const updateCurrentStep = async (req: Request) => {
const isStepCompleted =
project?.legacy_cms?.cms && project?.legacy_cms?.file_format;

// Delta migration: from iteration 2 onwards the flow has an extra "Map Entry" step (step 4),
// shifting Testing β†’ 5 and Migration β†’ 6. Resolve the step-number map for this project's
// iteration so the state machine progresses through the correct steps.
const steps = getStepperSteps(project?.iteration);

switch (project.current_step) {
case STEPPER_STEPS.LEGACY_CMS: {
if (project.status !== NEW_PROJECT_STATUS[0] || !isStepCompleted) {
Expand Down Expand Up @@ -998,7 +1004,7 @@ const updateCurrentStep = async (req: Request) => {
});
break;
}
case STEPPER_STEPS.CONTENT_MAPPING: {
case steps.CONTENT_MAPPING: {
if (
project.status === NEW_PROJECT_STATUS[0] ||
!isStepCompleted ||
Expand All @@ -1023,13 +1029,31 @@ const updateCurrentStep = async (req: Request) => {
) {
throw new NotFoundError(HTTP_TEXTS.PROJECT_NOT_FOUND);
}
data.projects[projectIndex].current_step = STEPPER_STEPS.TESTING;
// Delta iteration: Content Mapping β†’ Map Entry (step 4). Iteration 1: β†’ Testing (step 4).
data.projects[projectIndex].current_step =
steps.MAP_ENTRY ?? steps.TESTING;
data.projects[projectIndex].status = NEW_PROJECT_STATUS[4];
data.projects[projectIndex].updated_at = new Date().toISOString();
});
break;
}
// Map Entry β†’ Testing. Only reachable on delta iterations (step exists from iteration 2).
case steps.MAP_ENTRY: {
await ProjectModelLowdb.update((data: any) => {
if (
!data?.projects ||
!Array.isArray(data.projects) ||
!data.projects[projectIndex]
) {
throw new NotFoundError(HTTP_TEXTS.PROJECT_NOT_FOUND);
}
data.projects[projectIndex].current_step = steps.TESTING;
data.projects[projectIndex].status = NEW_PROJECT_STATUS[4];
data.projects[projectIndex].updated_at = new Date().toISOString();
});
break;
}
case STEPPER_STEPS.TESTING: {
case steps.TESTING: {
if (
project.status === NEW_PROJECT_STATUS[0] ||
!isStepCompleted ||
Expand All @@ -1056,13 +1080,13 @@ const updateCurrentStep = async (req: Request) => {
) {
throw new NotFoundError(HTTP_TEXTS.PROJECT_NOT_FOUND);
}
data.projects[projectIndex].current_step = STEPPER_STEPS.MIGRATION;
data.projects[projectIndex].current_step = steps.MIGRATION;
data.projects[projectIndex].status = NEW_PROJECT_STATUS[4];
data.projects[projectIndex].updated_at = new Date().toISOString();
});
break;
}
case STEPPER_STEPS.MIGRATION: {
case steps.MIGRATION: {
if (
project.status === NEW_PROJECT_STATUS[0] ||
!isStepCompleted ||
Expand All @@ -1089,7 +1113,7 @@ const updateCurrentStep = async (req: Request) => {
) {
throw new NotFoundError(HTTP_TEXTS.PROJECT_NOT_FOUND);
}
data.projects[projectIndex].current_step = STEPPER_STEPS.MIGRATION;
data.projects[projectIndex].current_step = steps.MIGRATION;
data.projects[projectIndex].status = NEW_PROJECT_STATUS[5];
data.projects[projectIndex].updated_at = new Date().toISOString();
});
Expand Down Expand Up @@ -1575,7 +1599,8 @@ const getMigratedStacks = async (req: Request) => {
(project: any) =>
project != null &&
project?.status === 5 &&
project?.current_step === 5 &&
// Project is on its final Execute step (6 on delta iterations, 5 otherwise).
project?.current_step === getStepperSteps(project?.iteration).MIGRATION &&
project?.destination_stack_id
)
.map((project: any) => project.destination_stack_id)
Expand Down
6 changes: 4 additions & 2 deletions api/src/services/runCli.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import fs from 'fs';
import { spawn } from 'child_process';
import { v4 } from 'uuid';
import { copyDirectory, createDirectoryAndFile } from '../utils/index.js';
import { CS_REGIONS, MIGRATION_DATA_CONFIG, DATABASE_FILES } from '../constants/index.js';
import { CS_REGIONS, MIGRATION_DATA_CONFIG, DATABASE_FILES, getStepperSteps } from '../constants/index.js';
import ProjectModelLowdb from '../models/project-lowdb.js';
import AuthenticationModel from '../models/authentication.js';
// import watchLogs from '../utils/watch.utils.js';
Expand Down Expand Up @@ -317,7 +317,9 @@ export const runCli = async (
true;
ProjectModelLowdb.data.projects[projectIndex].isMigrationStarted =
false;
ProjectModelLowdb.data.projects[projectIndex].current_step = 5;
// Migration completed β†’ land on the final Execute step (6 on delta iterations, 5 otherwise).
ProjectModelLowdb.data.projects[projectIndex].current_step =
getStepperSteps(ProjectModelLowdb.data.projects[projectIndex]?.iteration).MIGRATION;
ProjectModelLowdb.data.projects[projectIndex].status = 5;
await ProjectModelLowdb.write();
}
Expand Down
1 change: 0 additions & 1 deletion api/src/utils/entry-update-script.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,6 @@ module.exports = async ({
if (updateData && entry) {
for (const field of Object.keys(updateData)) {
if (isAssetField(updateData[field])) {
console.info('field is asset field');
updateData[field] = resolveAssetField(
field,
entryUid,
Expand Down
28 changes: 23 additions & 5 deletions api/src/utils/entry-update.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,18 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?:
entryMapperItems.map((item: { otherCmsEntryUid: string }) => item?.otherCmsEntryUid)
);

// Entries that already exist in Contentstack (have a contentstackEntryUid). These are removed
// from the import data so they are NOT re-created β€” regardless of isUpdate.
const csUidMap = new Map<string, string>();
// Subset of the above marked isUpdate β†’ collected into the update config so they get updated
// in Contentstack. Existing entries that are NOT isUpdate are simply left untouched.
const updateUidMap = new Map<string, string>();
for (const item of entryMapperItems) {
if (item.isUpdate) {
updateUidMap.set(item?.otherCmsEntryUid, item?.contentstackEntryUid);
if (item?.contentstackEntryUid) {
csUidMap.set(item?.otherCmsEntryUid, item?.contentstackEntryUid);
if (item?.isUpdate) {
updateUidMap.set(item?.otherCmsEntryUid, item?.contentstackEntryUid);
}
}
}

Expand Down Expand Up @@ -144,8 +152,17 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?:
let modified = false;
for (const key of Object?.keys(data)) {
if (sitecoreUids.has(key)) {
const csEntryUid = updateUidMap.get(key);
if (csEntryUid) {
const csEntryUid = csUidMap.get(key);
// No Contentstack entry uid β†’ this entry was never migrated, so leave it in
// the import data to be CREATED this iteration. Deleting it would silently
// drop the entry (data loss).
if (!csEntryUid) {
continue;
}

// Entry already exists in Contentstack. If it's marked isUpdate, collect it
// into the update config so it gets updated; otherwise it's left as-is.
if (updateUidMap.has(key)) {
const entryData = { ...data[key] };
delete entryData?.uid;

Expand All @@ -157,10 +174,11 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?:
writeLogEntry(`Entry "${key}" has been prepared for update in Contentstack as "${csEntryUid}"`, "removeEntriesFromDatabase", loggerPath);
}

// Existing entry β†’ remove from import data so it is NOT re-created.
delete data[key];
modified = true;
writeLogEntry(`Removed entry "${key}" from ${filePath}`, "removeEntriesFromDatabase", loggerPath);
writeLogEntry(`Entry "${key}" has been removed from migration data (will be updated instead of created)`, "removeEntriesFromDatabase", loggerPath);
writeLogEntry(`Entry "${key}" has been removed from migration data (exists in Contentstack)`, "removeEntriesFromDatabase", loggerPath);
}
}

Expand Down
Loading
Loading