Skip to content
Merged
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
24 changes: 24 additions & 0 deletions src/core/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ export class Push {
guid?: string;
locale?: string;
}> = [];
// PROD-2316: non-blocking notices (e.g. page modules dropped because their content
// couldn't be resolved). Shown in their own section; never affect success/exit code.
const syncWarningDetails: typeof syncFailureDetails = [];

results.forEach((result: PushResults) => {
// Track item-level failures from totalFailures
Expand All @@ -129,6 +132,9 @@ export class Push {
if (result.failureDetails && result.failureDetails.length > 0) {
syncFailureDetails.push(...result.failureDetails);
}
if (result.warningDetails && result.warningDetails.length > 0) {
syncWarningDetails.push(...result.warningDetails);
}
// Track operation-level failures
if (result.failed && result.failed.length > 0) {
result.failed.forEach((f) => {
Expand Down Expand Up @@ -230,6 +236,24 @@ export class Push {
}
}

// PROD-2316: dropped-module notices — a page pushed successfully, but one or more of
// its modules referenced content that couldn't be resolved on the target, so those
// modules were left off the pushed page. Non-blocking: the modules return automatically
// on a future push of the page once the content syncs.
if (syncWarningDetails.length > 0) {
console.log(ansiColors.yellow(`\n Page Module Warnings (non-blocking, ${syncWarningDetails.length}):`));
syncWarningDetails.forEach(({ name, error, pageID, contentID, guid, locale }) => {
const prefix = guid && locale ? `[${guid}][${locale}]` : guid ? `[${guid}]` : "";
console.log(ansiColors.yellow(` ${prefix} • ${name}: ${error}`));
if (pageID && guid && locale) {
console.log(ansiColors.gray(` ${getPageCmsLink(guid, locale, pageID)}`));
}
if (contentID && guid && locale) {
console.log(ansiColors.gray(` ${getContentCmsLink(guid, locale, contentID)}`));
}
});
}

// PROD-2311: post-publish mapping/version bookkeeping notices are NOT publish
// failures — show them under a non-blocking header so they don't read as broken.
if (mappingWarnings.length > 0) {
Expand Down
25 changes: 23 additions & 2 deletions src/lib/pushers/orchestrate-pushers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface PushResults {
publishablePageIdsByLocale: Map<string, number[]>;
// Individual failure details for error summary
failureDetails: FailureDetail[];
// PROD-2316: non-blocking notices (e.g. page modules dropped due to unresolvable content)
warningDetails: FailureDetail[];
}

export interface PusherConfig {
Expand Down Expand Up @@ -66,6 +68,7 @@ export class Pushers {
publishableContentIdsByLocale: new Map(),
publishablePageIdsByLocale: new Map(),
failureDetails: [],
warningDetails: [],
};

try {
Expand All @@ -84,6 +87,7 @@ export class Pushers {
results.publishableContentIdsByLocale = pushResults.publishableContentIdsByLocale;
results.publishablePageIdsByLocale = pushResults.publishablePageIdsByLocale;
results.failureDetails = pushResults.failureDetails;
results.warningDetails = pushResults.warningDetails;

// Calculate final duration
results.totalDuration = Date.now() - startTime;
Expand Down Expand Up @@ -155,6 +159,7 @@ export class Pushers {
publishableContentIdsByLocale: Map<string, number[]>;
publishablePageIdsByLocale: Map<string, number[]>;
failureDetails: FailureDetail[];
warningDetails: FailureDetail[];
}> {
const { locale: locales, elements: stateElements } = state;
const elements = stateElements.split(",");
Expand All @@ -170,6 +175,8 @@ export class Pushers {
const publishablePageIdsByLocale = new Map<string, number[]>();
// Collect individual failure details
const failureDetails: FailureDetail[] = [];
// PROD-2316: collect non-blocking warning details
const warningDetails: FailureDetail[] = [];

// PROD-2202: Models run FIRST so the model-mapping validation (the rename/reassignment
// mismatch detection in pushModels) fails the sync before any galleries or assets are
Expand Down Expand Up @@ -240,6 +247,9 @@ export class Pushers {
if (result.failureDetails) {
failureDetails.push(...result.failureDetails);
}
if (result.warningDetails) {
warningDetails.push(...result.warningDetails);
}
}
} catch (error: any) {
// Re-throw validation errors immediately to stop sync.
Expand Down Expand Up @@ -284,6 +294,9 @@ export class Pushers {
if (result.failureDetails) {
failureDetails.push(...result.failureDetails);
}
if (result.warningDetails) {
warningDetails.push(...result.warningDetails);
}

// Store per-locale IDs and also add to combined list
if (config === PUSH_OPERATIONS.content && localeContentIds.length > 0) {
Expand All @@ -308,6 +321,7 @@ export class Pushers {
publishableContentIdsByLocale,
publishablePageIdsByLocale,
failureDetails,
warningDetails,
};
} catch (error) {
console.error(ansiColors.red("Error during pusher execution:"), error);
Expand All @@ -331,7 +345,13 @@ export class Pushers {
publishableContentIds?: number[];
publishablePageIds?: number[];
elements: string[];
}): Promise<{ success: number; failures: number; skipped: number; failureDetails?: FailureDetail[] }> {
}): Promise<{
success: number;
failures: number;
skipped: number;
failureDetails?: FailureDetail[];
warningDetails?: FailureDetail[];
}> {
const elementData = sourceData[config.dataKey as keyof GuidEntities] || [];

// Skip if no data for this element type or element not requested
Expand All @@ -342,7 +362,7 @@ export class Pushers {
console.log(
ansiColors.yellow(`⚠️ Skipping ${config.description} for locale ${locale} - no data or filtered by --locales`)
);
return { success: 0, failures: 0, skipped: 0, failureDetails: [] };
return { success: 0, failures: 0, skipped: 0, failureDetails: [], warningDetails: [] };
}

this.config.onOperationStart?.(config.name, state.sourceGuid, state.targetGuid);
Expand Down Expand Up @@ -383,6 +403,7 @@ export class Pushers {
failures: pusherResult.failed || 0,
skipped: pusherResult.skipped || 0,
failureDetails: pusherResult.failureDetails || [],
warningDetails: pusherResult.warningDetails || [],
};
}

Expand Down
139 changes: 69 additions & 70 deletions src/lib/pushers/page-pusher/process-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { translateZoneNames } from "./translate-zone-names";
import { findPageInOtherLocale, OtherLocaleMapping } from "./find-page-in-other-locale";
import { Logs } from "core/logs";
import { state, getFailedContent, contentExistsInSourceData, contentExistsInOtherLocale } from "core/state";
import { PageModuleExtended } from "types/sourceData";
import { FailureDetail, PageModuleExtended } from "types/sourceData";
import { preflightReport } from "../../preflight/preflight-report";

interface Props {
Expand All @@ -25,7 +25,14 @@ interface Props {
logger: Logs;
}

export type PageProcessResult = { status: "success" | "skip" | "failure"; error?: string; contentID?: number };
export type PageProcessResult = {
status: "success" | "skip" | "failure";
error?: string;
contentID?: number;
// PROD-2316: non-blocking notices for modules dropped from this page because their content
// couldn't be resolved on the target. The page itself still pushes.
warnings?: FailureDetail[];
};

export async function processPage({
channel,
Expand Down Expand Up @@ -182,27 +189,11 @@ export async function processPage({
[key: string]: PageModuleExtended[];
};

// Content mapping validation - collect all content IDs that need mapping
const contentIdsToValidate: number[] = [];
for (const [zoneName, zoneModules] of Object.entries(mappedZones)) {
if (Array.isArray(zoneModules)) {
for (const module of zoneModules) {
if (module.item && typeof module.item === "object") {
const sourceContentId = module.item.contentid || module.item.contentId;
if (sourceContentId && sourceContentId > 0) {
contentIdsToValidate.push(sourceContentId);
}
}
}
}
}

// Content mapping validation (silent unless errors)

const contentMapper = new ContentItemMapper(sourceGuid, targetGuid, locale);
// Track first missing content mapping for error summary
let firstMissingContentError: string | null = null;
let firstMissingContentID: number | null = null;
// PROD-2316: modules whose content can't be resolved are DROPPED from the pushed page rather
// than failing the whole page. Collect every drop (not just the first) as a non-blocking
// warning so the run summary can report exactly what was left out and why.
const droppedModules: FailureDetail[] = [];

for (const [zoneName, zoneModules] of Object.entries(mappedZones)) {
const newZoneContent = [];
Expand Down Expand Up @@ -258,12 +249,29 @@ export async function processPage({
mappingError = `No content mapping for ${module.module} (contentID ${sourceContentId}) - content has never been synced or model may have changed`;
}

// Don't log individual errors inline - they'll appear in the final summary
// Capture first error and contentID for summary
if (!firstMissingContentError) {
firstMissingContentError = mappingError;
firstMissingContentID = sourceContentId;
}
// PROD-2316: drop this module from the pushed page (it is NOT added to
// newZoneContent) and record a non-blocking warning. The rest of the page still
// pushes. NOTE: the server's SavePage diff (BatchInsertPageItem's toBeDeletedList)
// REMOVES payload-absent modules from an existing target page, so a dropped module
// is genuinely removed on the target — the mapping below is therefore recorded as
// dirty (sourceVersionID 0) so the page re-pushes each sync and the module is
// restored automatically once its content becomes resolvable.
droppedModules.push({
name: page.name || `Page ${page.pageID}`,
error: `Dropped module ${module.module} — ${mappingError}`,
type: "page",
pageID: page.pageID,
contentID: sourceContentId,
guid: sourceGuid,
locale,
});
logger.page.skipped(
page,
`dropped module ${module.module} (contentID ${sourceContentId}) — ${mappingError}`,
locale,
channel,
targetGuid
);
}
} else {
// Module without content reference - keep it
Expand All @@ -278,43 +286,10 @@ export async function processPage({
mappedZones[zoneName] = newZoneContent;
}

// Content mapping validation - check which mappings were successful
if (contentIdsToValidate.length > 0) {
const mappingResults: { [contentId: number]: { found: boolean; targetId?: number; error?: string } } = {};
let foundMappings = 0;
let missingMappings = 0;

contentIdsToValidate.forEach((sourceContentId) => {
const contentMapping = contentMapper.getContentItemMappingByContentID(sourceContentId, "source");
const targetContentID = contentMapping?.targetContentID;
if (targetContentID) {
mappingResults[sourceContentId] = {
found: true,
targetId: targetContentID,
};
foundMappings++;
} else {
mappingResults[sourceContentId] = {
found: false,
error: targetContentID ? "Invalid target ID" : "No mapping found",
};
missingMappings++;
}
});

if (missingMappings > 0) {
console.error(
ansiColors.bgRed(
`✗ Page "${page.name}" failed - ${missingMappings}/${contentIdsToValidate.length} missing content mappings`
)
);
return {
status: "failure",
error: firstMissingContentError || `${missingMappings} missing content mappings`,
contentID: firstMissingContentID || undefined,
};
}
}
// PROD-2316: the hard-fail that previously aborted the whole page here whenever ANY module's
// content mapping was missing has been removed. Unresolvable modules are dropped above (with
// per-module warnings in `droppedModules`); the page itself still pushes with the modules
// that DID resolve.

// Check if page has any content left after filtering
const totalModules = Object.values(mappedZones).reduce((sum: number, zone) => {
Expand Down Expand Up @@ -365,10 +340,20 @@ export async function processPage({

// If the page originally had modules but now has none, that's a problem
// If it never had modules, that's fine (folder pages, etc.)
if (originalModuleCount > 0 && !existingPage && !isLegitimateEmptyPage(page)) {
// PROD-2316: this guard now also covers the UPDATE path (previously create-only). With
// partial drops allowed, a page whose EVERY module failed to resolve signals a systemic
// problem (e.g. the whole content phase failed) — pushing it would wipe all modules off the
// existing target page. Genuine source-side emptiness is unaffected: if the author removed
// every module in source, originalModuleCount is 0 and this never triggers.
if (originalModuleCount > 0 && droppedModules.length > 0 && !isLegitimateEmptyPage(page)) {
const lostModulesError = `Lost all ${originalModuleCount} modules during content mapping`;
console.error(`✗ Page "${page.name}" ${lostModulesError}`);
return { status: "failure", error: lostModulesError };
return {
status: "failure",
error: lostModulesError,
contentID: droppedModules[0]?.contentID,
warnings: droppedModules,
};
}
}

Expand Down Expand Up @@ -504,7 +489,20 @@ export async function processPage({
createdPageData.properties.versionID = savedPageVersionID; // Set version ID from batch result
}

pageMapper.addMapping(page, createdPageData); // Use original page for source key
if (droppedModules.length > 0) {
// PROD-2316: modules were dropped, so record the mapping with sourceVersionID 0
// ("never cleanly synced"). hasSourceChanged() then stays true on every subsequent
// sync, so the page keeps re-pushing until a push completes with no drops — which
// restores the dropped modules automatically once their content becomes resolvable.
// targetVersionID is still recorded accurately, so this never reads as a conflict.
const dirtySourcePage = {
...page,
properties: { ...page.properties, versionID: 0 },
} as mgmtApi.PageItem;
pageMapper.addMapping(dirtySourcePage, createdPageData);
} else {
pageMapper.addMapping(page, createdPageData); // Use original page for source key
}

const pageTypeDisplay =
{
Expand All @@ -518,7 +516,8 @@ export async function processPage({
} else {
logger.page.created(page, "created", locale, channel, targetGuid);
}
return { status: "success" }; // Success
// PROD-2316: surface any dropped-module notices alongside the success.
return { status: "success", warnings: droppedModules.length > 0 ? droppedModules : undefined };
} else {
let errorMsg: string;
if (batchFailedItems.length > 0 && batchFailedItems[0].error) {
Expand All @@ -540,7 +539,7 @@ export async function processPage({
channel,
targetGuid
);
return { status: "failure", error: errorMsg };
return { status: "failure", error: errorMsg, warnings: droppedModules.length > 0 ? droppedModules : undefined };
}
} else {
const errorMsg = "Unexpected response format";
Expand Down
11 changes: 10 additions & 1 deletion src/lib/pushers/page-pusher/process-sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as mgmtApi from "@agility/management-sdk";
import ansiColors from "ansi-colors";
import { state, getApiClient } from "../../../core/state";
import { PusherResult } from "../../../types/sourceData";
import { FailureDetail, PusherResult } from "../../../types/sourceData";
import { SitemapHierarchy } from "./sitemap-hierarchy";
import { PageMapper } from "../../mappers/page-mapper";
import { processPage } from "./process-page";
Expand All @@ -22,6 +22,8 @@ interface ReturnType {
guid?: string;
locale?: string;
}>;
// PROD-2316: non-blocking dropped-module notices collected from processPage results.
warningDetails: FailureDetail[];
}

interface Props {
Expand Down Expand Up @@ -72,6 +74,7 @@ export async function processSitemap({
skipped: 0,
publishableIds: [],
failureDetails: [],
warningDetails: [],
};

// Reverse the sitemap nodes to process them in the correct order
Expand Down Expand Up @@ -124,6 +127,11 @@ export async function processSitemap({
logger,
});

// PROD-2316: collect dropped-module notices regardless of the page's final status.
if (pageRes.warnings && pageRes.warnings.length > 0) {
returnData.warningDetails.push(...pageRes.warnings);
}

if (pageRes.status === "success") {
returnData.successful++;

Expand Down Expand Up @@ -180,6 +188,7 @@ export async function processSitemap({
returnData.skipped += childRes.skipped;
returnData.publishableIds.push(...childRes.publishableIds);
returnData.failureDetails.push(...childRes.failureDetails);
returnData.warningDetails.push(...childRes.warningDetails);

// Update previousPageID for next iteration
previousPageID = node.pageID;
Expand Down
Loading