From 026b18bed0a02016c482201e084e3ff5985a6769 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 13 Jul 2026 10:01:07 -0400 Subject: [PATCH] PROD-2306: clearer error when an item references a deleted linked list When a content item references a linked list / linked container that was deleted in the source, the create fails and the Management API's misleading "... does not exist" text was surfaced verbatim. Detect the deleted reference proactively (absent from the live source containers, which the pull prunes on delete) and replace the surfaced error with a clear, actionable message. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../content-pusher/content-batch-processor.ts | 56 +++++++++++++- .../detect-deleted-linked-list-references.ts | 73 +++++++++++++++++++ ...ect-deleted-linked-list-references.test.ts | 72 ++++++++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 src/lib/pushers/content-pusher/util/detect-deleted-linked-list-references.ts create mode 100644 src/lib/pushers/content-pusher/util/tests/detect-deleted-linked-list-references.test.ts 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'"); + }); +});