Skip to content
Draft
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
5 changes: 3 additions & 2 deletions src/core/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,9 +341,10 @@ 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.
// on a future push of the page once the content syncs. PROD-2729: also pages and content
// items whose GEO data the target rejected and that were saved without it.
if (syncWarningDetails.length > 0) {
console.log(ansiColors.yellow(`\n Page Component Warnings (non-blocking, ${syncWarningDetails.length}):`));
console.log(ansiColors.yellow(`\n 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}`));
Expand Down
81 changes: 74 additions & 7 deletions src/lib/pushers/content-pusher/content-batch-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ import { fileOperations } from "core/fileOperations";
import { collectUnresolvedContentReferences } from "./util/has-unresolved-content-references";
import { Logs } from "core/logs";
import { state, getFailedContent } from "core/state";
import { FailureDetail } from "types/sourceData";
import {
GEO_NOT_COPIED_CODE,
geoNotCopiedMessage,
getItemGeoValidationErrors,
withoutGeoFields,
} from "../page-pusher/geo-fields";

/**
* PROD-2542: one source item and the payload built from it. Prepared items travel as pairs so the
Expand Down Expand Up @@ -122,6 +129,7 @@ export class ContentBatchProcessor {
let totalSkippedCount = 0;
const allSuccessfulItems: BatchSuccessItem[] = [];
const allFailedItems: BatchFailedItem[] = [];
const allWarnings: FailureDetail[] = [];
const startTime = Date.now();

for (let i = 0; i < contentBatches.length; i++) {
Expand Down Expand Up @@ -171,16 +179,37 @@ export class ContentBatchProcessor {
continue;
}
// Both lists derive from the same paired array, so index i of one is index i of the other.
const contentPayloads = prepared.map((p) => p.payload);
let contentPayloads = prepared.map((p) => p.payload);
const includedItems = prepared.map((p) => p.source);

// Execute bulk upload using saveContentItems API with returnBatchID flag
const batchIDResult = await this.config.apiClient.contentMethods.saveContentItems(
contentPayloads,
this.config.targetGuid,
this.config.locale,
true // returnBatchID flag
);
const saveBatch = (payloads: any[]) =>
this.config.apiClient.contentMethods.saveContentItems(
payloads,
this.config.targetGuid,
this.config.locale,
true // returnBatchID flag
);

let batchIDResult: any;
try {
batchIDResult = await saveBatch(contentPayloads);
} catch (saveError: any) {
// PROD-2729: the Management API validates every item's GEO data before queueing and
// rejects the WHOLE request with one 400 naming "items[i].geo.…". One bad JSON-LD block
// must not fail a batch of up to 100 items, so save again with the offending GEO
// properties left out of the named items (deleted, never nulled: the target keeps its
// own values) and report each as a non-blocking warning.
const retry = this.withoutRejectedGeo(saveError, prepared);
if (!retry) throw saveError;
contentPayloads = retry.payloads;
prepared.forEach((p, idx) => (p.payload = contentPayloads[idx]));
batchIDResult = await saveBatch(contentPayloads);
allWarnings.push(...retry.warnings);
retry.warnings.forEach((w) =>
console.warn(ansiColors.yellow(`⚠️ ${w.name} (content ${w.contentID}): ${w.error}`))
);
}

// Extract batch ID from array response
const batchID = Array.isArray(batchIDResult) ? batchIDResult[0] : batchIDResult;
Expand Down Expand Up @@ -322,9 +351,43 @@ export class ContentBatchProcessor {
successfulItems: allSuccessfulItems,
failedItems: allFailedItems,
publishableIds: publishableItems.map((item) => item.newContentId),
warnings: allWarnings.length > 0 ? allWarnings : undefined,
};
}

/**
* PROD-2729: if `error` is the Management API's 400 rejecting GEO data in a batch save, the
* payloads to send again (offending GEO properties deleted from the items it names) and one
* `geoNotCopied` warning per item that changed. Undefined when the error isn't a GEO 400, or when
* no payload carries the named properties (a retry would fail the same way).
*/
private withoutRejectedGeo(
error: any,
prepared: PreparedContentPayload[]
): { payloads: any[]; warnings: FailureDetail[] } | undefined {
const byIndex = getItemGeoValidationErrors(error, prepared.length);
if (!byIndex) return undefined;

const warnings: FailureDetail[] = [];
const payloads = prepared.map((p, idx) => {
const validation = byIndex.get(idx);
if (!validation) return p.payload;
const stripped = withoutGeoFields(p.payload, validation.strip);
if (JSON.stringify(stripped) === JSON.stringify(p.payload)) return p.payload;
warnings.push({
name: p.source?.properties?.referenceName || `Content ${p.source?.contentID}`,
error: geoNotCopiedMessage(validation),
type: "content",
contentID: p.source?.contentID,
guid: this.config.sourceGuid,
locale: this.config.locale,
code: GEO_NOT_COPIED_CODE,
});
return stripped;
});
return warnings.length > 0 ? { payloads, warnings } : undefined;
}

/**
* Create batches of content items for bulk processing
*/
Expand Down Expand Up @@ -564,6 +627,10 @@ export class ContentBatchProcessor {
const defaultScripts = { top: null, bottom: null };

// STEP 8: Create payload using EXACT original logic
// PROD-2729: the spread carries the item's `geo` (dynamic page list items) and any GEO
// `seo` keys (title, canonicalUrl, robots, ogImage) through as pulled. `defaultSeo` only
// fills the five older keys when `seo` is null, and nothing here may set `geo` (or a GEO
// key) to null: on save an omitted property keeps the target's value, null clears it.
const payload = {
...contentItem, // Start with original content item
contentID: existingContentID,
Expand Down
7 changes: 7 additions & 0 deletions src/lib/pushers/content-pusher/content-pusher.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Removed finder imports - using mapper directly
import ansiColors from "ansi-colors";
import { FailureDetail } from "types/sourceData";
// Removed ContentBatchProcessor import - individual pusher only handles individual processing
import { getLoggerForGuid, state, registerFailedContent } from "core/state";
import { ContentItemMapper } from "lib/mappers/content-item-mapper";
Expand Down Expand Up @@ -68,6 +69,8 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content
model?: string;
fields?: string[];
}> = [];
// PROD-2729: non-blocking notices (an item's GEO data left out of the save).
const allWarningDetails: FailureDetail[] = [];

// PROD-2542: items the filter rejected client-side (missing required field on the target model).
// Register each in the failed-content registry right away, so the next pass's parents and the
Expand Down Expand Up @@ -151,6 +154,7 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content
totalSkipped += filteredLinkedContentItems.skippedCount;
totalSkipped += linkedResult.skippedCount;
allPublishableIds.push(...linkedResult.publishableIds);
if (linkedResult.warnings) allWarningDetails.push(...linkedResult.warnings);
// Collect failure details for error summary and register in failed content registry
if (linkedResult.failedItems && linkedResult.failedItems.length > 0) {
linkedResult.failedItems.forEach((item) => {
Expand Down Expand Up @@ -214,6 +218,7 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content
totalSkipped += filteredNormalContentItems.skippedCount;
totalSkipped += normalResult.skippedCount;
allPublishableIds.push(...normalResult.publishableIds);
if (normalResult.warnings) allWarningDetails.push(...normalResult.warnings);
// Collect failure details for error summary and register in failed content registry
if (normalResult.failedItems && normalResult.failedItems.length > 0) {
normalResult.failedItems.forEach((item) => {
Expand Down Expand Up @@ -244,6 +249,7 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content
skipped: totalSkipped,
publishableIds: allPublishableIds,
failureDetails: allFailureDetails,
warningDetails: allWarningDetails,
};
} catch (batchError: any) {
console.error(ansiColors.red(`❌ Batch processing failed: ${batchError.message}`));
Expand All @@ -254,6 +260,7 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content
skipped: totalSkipped,
publishableIds: allPublishableIds,
failureDetails: [...allFailureDetails, { name: "Batch processing", error: batchError.message }],
warningDetails: allWarningDetails,
};
}
}
Loading
Loading