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
56 changes: 55 additions & 1 deletion src/lib/pushers/content-pusher/content-batch-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
/******
Expand All @@ -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<string>;

constructor(config: ContentBatchConfig) {
this.config = {
Expand All @@ -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<string> | null {
if (this.liveSourceContainerRefNames === undefined) {
const refNames = new Set<string>();
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()
Expand Down Expand Up @@ -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),
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <name> 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<string>();

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>): 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})`
);
}
Original file line number Diff line number Diff line change
@@ -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'");
});
});
Loading