diff --git a/src/lib/pushers/content-pusher/content-batch-processor.ts b/src/lib/pushers/content-pusher/content-batch-processor.ts index 893a99e0..4744e9b7 100644 --- a/src/lib/pushers/content-pusher/content-batch-processor.ts +++ b/src/lib/pushers/content-pusher/content-batch-processor.ts @@ -12,6 +12,11 @@ import { ContentBatchConfig, } from "./util/types"; import { findContentInOtherLocale } from "./util/find-content-in-other-locale"; +import { + findDeletedLinkedListReferences, + buildDeletedLinkedListMessage, +} from "./util/detect-deleted-linked-list-references"; +import { fileOperations } from "core/fileOperations"; import { Logs } from "core/logs"; import { state } from "core/state"; /****** @@ -24,6 +29,10 @@ import { state } from "core/state"; */ export class ContentBatchProcessor { private config: ContentBatchConfig; + // PROD-2306: lazily-built, lower-cased set of linked-list/container reference names + // that still exist in the source. Used to tell a *deleted* linked-list reference apart + // from a merely "missing" one when clarifying create failures. + private liveSourceContainerRefNames?: Set; constructor(config: ContentBatchConfig) { this.config = { @@ -32,6 +41,50 @@ export class ContentBatchProcessor { }; } + /** + * PROD-2306: Build (and cache) the set of container/linked-list reference names still + * present in the source. When a source linked list is deleted, the pull removes its + * container file, so absence here is the signal that a reference is deleted-not-missing. + * Returns null when no source containers were pulled at all (e.g. containers out of scope), + * so we don't misreport every reference as deleted. + */ + private getLiveSourceContainerRefNames(): Set | null { + if (this.liveSourceContainerRefNames === undefined) { + const refNames = new Set(); + try { + const sourceFileOps = new fileOperations(this.config.sourceGuid); + const containers = sourceFileOps.readJsonFilesFromFolder("containers"); + for (const container of containers) { + if (container?.referenceName) { + refNames.add(String(container.referenceName).toLowerCase()); + } + } + } catch { + // Best-effort: if we can't read source containers, skip clarification. + } + this.liveSourceContainerRefNames = refNames; + } + return this.liveSourceContainerRefNames.size > 0 ? this.liveSourceContainerRefNames : null; + } + + /** + * PROD-2306: If a failed content item references a linked list that was deleted in the + * source, replace the misleading server error (surfaced verbatim, e.g. "... does not + * exist") with a clear, actionable message. Detection is proactive — based on the + * reference being absent from the live source containers — rather than string-matching + * the server text. + */ + private clarifyFailedItemError(contentItem: mgmtApi.ContentItem, originalError: string): string { + const liveRefNames = this.getLiveSourceContainerRefNames(); + if (!liveRefNames) return originalError; + + const deletedRefs = findDeletedLinkedListReferences(contentItem?.fields, liveRefNames); + if (deletedRefs.length === 0) return originalError; + + const itemRefName = contentItem?.properties?.referenceName || "unknown"; + return buildDeletedLinkedListMessage(itemRefName, deletedRefs, originalError); + } + /** * Process content items in batches using saveContentItems API * NOTE: Content items should already be filtered by the caller using filterContentItemsForProcessing() @@ -141,7 +194,8 @@ export class ContentBatchProcessor { })), failedItems: failedItems.map((item) => ({ originalContent: item.originalItem, - error: item.error, + // PROD-2306: clarify the error when the item references a deleted linked list. + error: this.clarifyFailedItemError(item.originalItem as mgmtApi.ContentItem, item.error), })), publishableIds: publishableSuccessItems.map((item) => item.newId), }; diff --git a/src/lib/pushers/content-pusher/util/detect-deleted-linked-list-references.ts b/src/lib/pushers/content-pusher/util/detect-deleted-linked-list-references.ts new file mode 100644 index 00000000..bcb47278 --- /dev/null +++ b/src/lib/pushers/content-pusher/util/detect-deleted-linked-list-references.ts @@ -0,0 +1,73 @@ +/** + * PROD-2306: Detect when a content item references a linked list / linked container + * that has been DELETED in the source instance. + * + * Background: when a source linked list is deleted, the pull removes its container + * file (see download-containers.ts, which unlinks containers no longer returned by + * the source). The linked-list reference, however, still lives on any content item + * that pointed at it. The CLI (correctly) will not recreate a deleted container on + * the target, so the create fails and the Management API surfaces a misleading + * "Cannot create this item does not exist" verbatim. + * + * These helpers let us proactively identify the deleted reference so we can emit a + * clear, actionable message instead of relying on / string-matching the server text. + */ + +/** + * Recursively collect every linked-list / linked-container reference name found in a + * content item's fields. A linked-list field value carries a `referencename` + * (or `referenceName`) pointing at the container it links to. + */ +export function collectAllReferenceNames(fields: any): string[] { + const found = new Set(); + + function walk(node: any): void { + if (!node) return; + + if (Array.isArray(node)) { + for (const v of node) walk(v); + return; + } + + if (typeof node === "object") { + const rn = (node as any).referencename ?? (node as any).referenceName; + if (typeof rn === "string" && rn.length > 0) { + found.add(rn); + } + for (const key of Object.keys(node)) { + walk((node as any)[key]); + } + } + } + + walk(fields); + return Array.from(found); +} + +/** + * Given a content item's fields and the set of linked-list/container reference names + * that still exist in the source (lower-cased), return the referenced names that are + * NOT present in the source — i.e. linked lists that were deleted in the source. + */ +export function findDeletedLinkedListReferences(fields: any, liveSourceContainerRefNames: Set): string[] { + return collectAllReferenceNames(fields).filter((rn) => !liveSourceContainerRefNames.has(rn.toLowerCase())); +} + +/** + * Build the clear, actionable error message for an item that failed because it + * references a deleted linked list. Keeps the original server error appended for + * triage context. + */ +export function buildDeletedLinkedListMessage( + itemReferenceName: string, + deletedRefNames: string[], + originalError: string +): string { + const names = deletedRefNames.map((n) => `'${n}'`).join(", "); + const subject = deletedRefNames.length > 1 ? `deleted linked lists ${names}` : `a deleted linked list ${names}`; + return ( + `Content item '${itemReferenceName}' references ${subject} — ` + + `clear the stale reference in the source or restore the list. ` + + `(original error: ${originalError})` + ); +} diff --git a/src/lib/pushers/content-pusher/util/tests/detect-deleted-linked-list-references.test.ts b/src/lib/pushers/content-pusher/util/tests/detect-deleted-linked-list-references.test.ts new file mode 100644 index 00000000..c3252203 --- /dev/null +++ b/src/lib/pushers/content-pusher/util/tests/detect-deleted-linked-list-references.test.ts @@ -0,0 +1,72 @@ +import { + collectAllReferenceNames, + findDeletedLinkedListReferences, + buildDeletedLinkedListMessage, +} from "../detect-deleted-linked-list-references"; + +describe("collectAllReferenceNames", () => { + it("returns empty array for null/undefined/empty", () => { + expect(collectAllReferenceNames(null)).toEqual([]); + expect(collectAllReferenceNames(undefined)).toEqual([]); + expect(collectAllReferenceNames({})).toEqual([]); + }); + + it("collects referencename (lowercase) and referenceName (camelCase)", () => { + const fields = { + cards: { referencename: "winnersgallery_linkcard", fulllist: true }, + other: { referenceName: "another_list" }, + }; + expect(collectAllReferenceNames(fields).sort()).toEqual(["another_list", "winnersgallery_linkcard"]); + }); + + it("collects references regardless of fulllist flag", () => { + const fields = { link: { referencename: "some_list", fulllist: false } }; + expect(collectAllReferenceNames(fields)).toEqual(["some_list"]); + }); + + it("dedupes repeated reference names", () => { + const fields = [{ referencename: "dup" }, { referencename: "dup" }]; + expect(collectAllReferenceNames(fields)).toEqual(["dup"]); + }); +}); + +describe("findDeletedLinkedListReferences", () => { + it("returns references not present in the live source container set", () => { + const fields = { + a: { referencename: "winnersgallery_linkcard_deleted" }, + b: { referencename: "live_list" }, + }; + const live = new Set(["live_list"]); + expect(findDeletedLinkedListReferences(fields, live)).toEqual(["winnersgallery_linkcard_deleted"]); + }); + + it("is case-insensitive against the live set", () => { + const fields = { a: { referencename: "Live_List" } }; + const live = new Set(["live_list"]); + expect(findDeletedLinkedListReferences(fields, live)).toEqual([]); + }); + + it("returns empty when all references are live", () => { + const fields = { a: { referencename: "live_list" } }; + expect(findDeletedLinkedListReferences(fields, new Set(["live_list"]))).toEqual([]); + }); +}); + +describe("buildDeletedLinkedListMessage", () => { + it("produces a clear, actionable message and preserves the original error", () => { + const msg = buildDeletedLinkedListMessage( + "winnersgallery_linkcard", + ["winnersgallery_linkcard_linke909a7"], + "Cannot create this item winnersgallery_linkcard_linke909a7 does not exist" + ); + expect(msg).toContain("Content item 'winnersgallery_linkcard' references a deleted linked list"); + expect(msg).toContain("'winnersgallery_linkcard_linke909a7'"); + expect(msg).toContain("clear the stale reference in the source or restore the list"); + expect(msg).toContain("original error: Cannot create this item"); + }); + + it("uses plural wording for multiple deleted lists", () => { + const msg = buildDeletedLinkedListMessage("item", ["a", "b"], "err"); + expect(msg).toContain("references deleted linked lists 'a', 'b'"); + }); +});