From 38b3a3dc4933d5467588040f56750e47a6ed1e27 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 15 Jul 2026 13:49:21 -0400 Subject: [PATCH 1/5] PROD-2315: handle cross-kind same-name model collision instead of a blind 409 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a model shares a referenceName across instances but is a different KIND (content vs component/module), Agility forbids the collision, so a create can never succeed. Previously model-pusher fell through to create and ate a raw server 409, and the actionable detail lived only in the push-log file — the ERROR SUMMARY showed a generic "Failed to create model". One collision also cascaded into every dependent container/content/page. Tier 1: - Detect the cross-kind collision up front (same referenceName, both types known and differing) and fail the model with an actionable message ("... is a component model on the source, but a content model with that reference name already exists on the target — rename one, then re-sync"), skipping the guaranteed-409 saveModel call. Reported as a conflict under --preflight. - Surface the server's actual error message for ANY create failure in the ERROR SUMMARY (createNewModel now returns the reason) instead of only the generic message; the detail was previously push-log-only. Type-unknown pulls are unaffected: modelTypeMatches falls back to name-only, so collision detection only fires when both kinds are known and differ. NOT in this change (Tier 2, follow-up): suppressing the dependent container/content/page cascade. The root model error is now clear; secondary "mapping not found" failures still appear. Updated the PROD-2211 type-blind-lookup test: a same-name different-type model is no longer blindly created (saveModel not called) but flagged as a collision failure. Added unit tests for collision failure, type-unknown fallback, same-kind adopt, preflight conflict, and server-error surfacing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/pushers/model-pusher.ts | 74 ++++++++++++-- src/lib/pushers/tests/model-pusher.test.ts | 109 ++++++++++++++++++++- 2 files changed, 173 insertions(+), 10 deletions(-) diff --git a/src/lib/pushers/model-pusher.ts b/src/lib/pushers/model-pusher.ts index 75f8f4b..b71f29c 100644 --- a/src/lib/pushers/model-pusher.ts +++ b/src/lib/pushers/model-pusher.ts @@ -20,6 +20,26 @@ function modelTypeMatches(a: mgmtApi.Model, b: mgmtApi.Model): boolean { return aType === bType; } +/** Human-readable model kind for messages. 1 = content, 2 = component/module. */ +function modelKindName(model: mgmtApi.Model): string { + const t = (model as any)?.contentDefinitionTypeID; + return t === 1 ? "content" : t === 2 ? "component/module" : `type ${t}`; +} + +/** + * PROD-2315: message for a cross-kind reference-name collision — a same-named model exists on both + * sides but as different kinds (content vs component/module). Agility forbids content and component + * models from sharing a reference name, so this can never be created; the user must reconcile it. + */ +function crossKindCollisionMessage(source: mgmtApi.Model, target: mgmtApi.Model): string { + return ( + `Model "${source.referenceName}" is a ${modelKindName(source)} model on the source, but a ` + + `${modelKindName(target)} model with that reference name already exists on the target. ` + + `Agility does not allow content and component models to share a reference name — ` + + `rename one of them (or remove the target model), then re-sync.` + ); +} + /** * Re-query the target for a model matching (referenceName, contentDefinitionTypeID). * @@ -82,6 +102,8 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp let shouldUpdateFields = []; let shouldSkip: { model: mgmtApi.Model; reason: string }[] = []; let stubCreated = []; + // PROD-2315: same reference name on both sides but a different kind (content vs component). + const crossKindConflicts: { model: mgmtApi.Model; target: mgmtApi.Model }[] = []; // PROD-2250: process source models that already have a mapping before brand-new // (unmapped) models. Reconciling existing target models first — using their known @@ -180,6 +202,22 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp } } + // PROD-2315: a target model with the same referenceName exists, but as a different KIND + // (content vs component/module). modelTypeMatches() already excluded it from + // targetModelByReference, so it would otherwise fall through to "create" and hit a guaranteed + // server 409 (Agility forbids content + component models sharing a name). Because + // targetModelByReference used modelTypeMatches — which returns true when either side's type is + // unknown — crossKindTarget is non-null ONLY when both types are known and differ. Type-unknown + // pulls are unaffected and keep today's behavior. + const crossKindTarget = + !sourceMapping && !targetModelByReference + ? targetData.find((t) => t.referenceName === sourceModel.referenceName) || null + : null; + if (crossKindTarget) { + crossKindConflicts.push({ model: sourceModel, target: crossKindTarget }); + continue; + } + if (!sourceMapping && !targetModel) { shouldCreateStub.push(sourceModel); continue; @@ -239,6 +277,15 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp detail: reason, }); } + // PROD-2315: preview cross-kind collisions as conflicts (a real run would fail them). + for (const { model, target } of crossKindConflicts) { + preflightReport.record({ + phase: "Models", + action: "conflict", + name: model.referenceName, + detail: crossKindCollisionMessage(model, target), + }); + } return { status: "success", successful: shouldCreateStub.length + shouldUpdateFields.length, @@ -249,14 +296,18 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp } for (const model of shouldCreateStub) { - const result = await createNewModel(model, referenceMapper, apiClient, targetGuid[0], logger); + const { result, error } = await createNewModel(model, referenceMapper, apiClient, targetGuid[0], logger); if (result === "created") { stubCreated.push(model); } else { failed++; failureDetails.push({ name: model.referenceName, - error: `Failed to create model "${model.referenceName}" (ID: ${model.id})`, + // PROD-2315: surface the server's reason (e.g. the 409 detail) in the ERROR SUMMARY instead + // of the generic message; the detail was previously only in the push-log file. + error: error + ? `Failed to create model "${model.referenceName}" (ID: ${model.id}): ${error}` + : `Failed to create model "${model.referenceName}" (ID: ${model.id})`, guid: sourceGuid[0], }); } @@ -294,6 +345,16 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp skipped++; } + // PROD-2315: cross-kind reference-name collisions can never be created (Agility forbids content and + // component models sharing a name). Fail them with an actionable message that reaches the ERROR + // SUMMARY, rather than attempting a create that is guaranteed to 409. + for (const { model, target } of crossKindConflicts) { + const message = crossKindCollisionMessage(model, target); + logger.model.error(model, new Error(message), targetGuid[0]); + failed++; + failureDetails.push({ name: model.referenceName, error: message, guid: sourceGuid[0] }); + } + return { status: "success", successful, @@ -312,7 +373,7 @@ const createNewModel = async ( apiClient: mgmtApi.ApiClient, targetGuid: string, logger: Logs -): Promise<"created" | "updated" | "skipped" | "failed"> => { +): Promise<{ result: "created" | "failed"; error?: string }> => { try { // process the model without fields const createPayload = { @@ -324,7 +385,7 @@ const createNewModel = async ( const newModel = await apiClient.modelMethods.saveModel(createPayload, targetGuid); logger.model.created(model, "created", targetGuid); referenceMapper.addMapping(model, newModel); - return "created"; + return { result: "created" }; } catch (error: any) { // PROD-2211: saveModel can reject even though the API created the model server-side. Before // declaring failure — which leaves the mapping file without a row and wedges future syncs — @@ -334,10 +395,11 @@ const createNewModel = async ( if (recovered) { logger.model.created(model, "created", targetGuid); referenceMapper.addMapping(model, recovered); - return "created"; + return { result: "created" }; } logger.model.error(model, error, targetGuid); - return "failed"; + // PROD-2315: return the server's message so the caller can surface it in the ERROR SUMMARY. + return { result: "failed", error: error?.message ? String(error.message) : undefined }; } }; diff --git a/src/lib/pushers/tests/model-pusher.test.ts b/src/lib/pushers/tests/model-pusher.test.ts index 67884ab..ad0b069 100644 --- a/src/lib/pushers/tests/model-pusher.test.ts +++ b/src/lib/pushers/tests/model-pusher.test.ts @@ -340,9 +340,8 @@ describe("pushModels — exists in target without mapping, non-default (PROD-221 expect(mapper.getModelMappingByID(501, "source")?.targetID).toBe(10); }); - it("does NOT adopt a same-name target model of a different type (type-blind lookup)", async () => { - const createdStub = makeModel({ id: 888, referenceName: "PromoBanner", contentDefinitionTypeID: 2 }); - const saveModel = jest.fn().mockResolvedValue(createdStub); + it("does NOT adopt a same-name target model of a different type; flags it as a cross-kind collision (type-blind lookup + PROD-2315)", async () => { + const saveModel = jest.fn(); jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel)); const { pushModels } = await import("../model-pusher"); @@ -352,7 +351,11 @@ describe("pushModels — exists in target without mapping, non-default (PROD-221 const result = await pushModels([sourceModel], [targetContentList]); - expect(saveModel).toHaveBeenCalled(); // goes through create, not adopted + // Not adopted (types differ), and — PROD-2315 — no longer blindly created: recognized as a + // forbidden cross-kind reference-name collision and failed with an actionable message rather + // than attempting a create that is guaranteed to 409. + expect(saveModel).not.toHaveBeenCalled(); + expect(result.failed).toBe(1); expect(result.skipped).toBe(0); }); }); @@ -479,3 +482,101 @@ describe("pushModels — mapped-before-unmapped ordering (PROD-2250)", () => { expect(result.successful).toBe(4); }); }); + +// ─── pushModels — cross-kind reference-name collision (PROD-2315) ────────────── + +describe("pushModels — cross-kind reference-name collision (PROD-2315)", () => { + it("fails a same-name model of a different kind with an actionable message instead of creating it", async () => { + const saveModel = jest.fn(); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel)); + + const { pushModels } = await import("../model-pusher"); + + // Source LinkCard is a component (type 2); target LinkCard is a content model (type 1). + const sourceModel = makeModel({ referenceName: "LinkCard", contentDefinitionTypeID: 2 }); + const targetModel = makeModel({ id: 500, referenceName: "LinkCard", contentDefinitionTypeID: 1 }); + + const result = await pushModels([sourceModel], [targetModel]); + + // Guaranteed-409 create is never attempted… + expect(saveModel).not.toHaveBeenCalled(); + // …and it is reported as a failure with an actionable message. + expect(result.failed).toBe(1); + expect(result.successful).toBe(0); + expect(result.failureDetails).toHaveLength(1); + expect(result.failureDetails![0].name).toBe("LinkCard"); + expect(result.failureDetails![0].error).toMatch(/component\/module model on the source/); + expect(result.failureDetails![0].error).toMatch(/content model with that reference name already exists/); + }); + + it("does NOT flag a same-name model when the kinds are unknown (falls back to adopt-by-reference)", async () => { + const saveModel = jest.fn(); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel)); + + const { pushModels } = await import("../model-pusher"); + + // Neither side has contentDefinitionTypeID → modelTypeMatches() is true → adopt, not a collision. + const now = new Date().toISOString(); + const sourceModel = makeModel({ referenceName: "AmbiguousKind", lastModifiedDate: now }); + const targetModel = makeModel({ id: 501, referenceName: "AmbiguousKind", lastModifiedDate: now }); + + const result = await pushModels([sourceModel], [targetModel]); + + expect(saveModel).not.toHaveBeenCalled(); // adopted, not created + expect(result.failed).toBe(0); + }); + + it("does NOT flag a same-name same-kind model (still adopts by reference, PROD-2211)", async () => { + const saveModel = jest.fn(); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel)); + + const { pushModels } = await import("../model-pusher"); + + const now = new Date().toISOString(); + const sourceModel = makeModel({ referenceName: "SameKind", contentDefinitionTypeID: 1, lastModifiedDate: now }); + const targetModel = makeModel({ id: 502, referenceName: "SameKind", contentDefinitionTypeID: 1, lastModifiedDate: now }); + + const result = await pushModels([sourceModel], [targetModel]); + + expect(saveModel).not.toHaveBeenCalled(); + expect(result.failed).toBe(0); + }); + + it("previews the collision as a conflict under --preflight without calling saveModel", async () => { + const saveModel = jest.fn(); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel)); + state.preflight = true; + + const { pushModels } = await import("../model-pusher"); + + const sourceModel = makeModel({ referenceName: "LinkCard", contentDefinitionTypeID: 2 }); + const targetModel = makeModel({ id: 503, referenceName: "LinkCard", contentDefinitionTypeID: 1 }); + + const result = await pushModels([sourceModel], [targetModel]); + + expect(saveModel).not.toHaveBeenCalled(); + expect(result.status).toBe("success"); // preflight is a dry run + }); +}); + +// ─── pushModels — create failure surfaces the server error (PROD-2315) ───────── + +describe("pushModels — create failure surfaces the server error (PROD-2315)", () => { + it("includes the server error message in failureDetails when a create fails", async () => { + const saveModel = jest + .fn() + .mockRejectedValue(new Error("A content model with the reference name 'Foo' already exists.")); + // getContentModules returns [] (default) so findTargetModelAfterSave finds no recovery. + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel)); + + const { pushModels } = await import("../model-pusher"); + + const sourceModel = makeModel({ referenceName: "Foo" }); + + const result = await pushModels([sourceModel], []); + + expect(result.failed).toBe(1); + expect(result.failureDetails).toHaveLength(1); + expect(result.failureDetails![0].error).toMatch(/A content model with the reference name 'Foo' already exists\./); + }); +}); From 4a0065a3055f38ee3c616f8617aa00a004ebae81 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 15 Jul 2026 14:13:51 -0400 Subject: [PATCH 2/5] PROD-2315 (Tier 2): attribute + group the cross-kind collision cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 made the root model collision a clear failure, but one collision still cascaded into ~8 misleading secondary failures ("Target model mapping not found", "No content mapping ... model may have changed") across the dependent container, its content items, and every page referencing them. Tier 2 attributes those dependents to the real cause and collapses them: - New blocked-model registry in state (mirrors failedContentRegistry): keyed by reference name with a by-ID lookup; cleared at sync start. model-pusher registers each cross-kind collision. - container-pusher: a container whose model mapping is missing because the model is blocked now reports "blocked by model conflict '': " instead of the generic "Target model mapping not found", tagged for grouping. - content-pusher: content items skipped because their model is blocked are registered in the failed-content registry with the collision reason, so any referencing page inherits the real cause (process-page already consults that registry), and are tagged for grouping. - process-page/process-sitemap: thread the blocking model through the page failure so page failures group with the rest. - push.ts ERROR SUMMARY: failures tagged blockedBy are pulled out of the raw "Sync Failures" list and rendered as one line per blocking model (": N dependent item(s) not synced — ") with the affected items listed beneath. Net effect: 1 collision -> 1 root failure + 1 grouped block line, instead of 1 -> 9 scattered failures. Decisions (per discussion): relabel + dedupe; continue the run (no hard halt); exit-code behavior left to PROD-2310; collision-only (does not generalize to other failed-model causes yet). Tests: blocked-model registry unit tests; failed-content registry carries the blocking model; model-pusher collision test asserts the block is registered. Cross-pusher grouping is covered by the manual end-to-end verification. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/push.ts | 37 ++++++++++++-- src/core/state.ts | 51 +++++++++++++++++-- src/core/tests/state.test.ts | 39 ++++++++++++++ src/lib/pushers/container-pusher.ts | 44 ++++++++++------ .../pushers/content-pusher/content-pusher.ts | 24 ++++++++- src/lib/pushers/model-pusher.ts | 25 +++++---- src/lib/pushers/page-pusher/process-page.ts | 13 ++++- .../pushers/page-pusher/process-sitemap.ts | 2 + src/lib/pushers/tests/model-pusher.test.ts | 6 +++ src/types/sourceData.ts | 3 ++ 10 files changed, 208 insertions(+), 36 deletions(-) diff --git a/src/core/push.ts b/src/core/push.ts index f310bf2..26bd6e1 100644 --- a/src/core/push.ts +++ b/src/core/push.ts @@ -4,6 +4,8 @@ import { getLogger, state, clearFailedContentRegistry, + clearBlockedModelRegistry, + getBlockedModel, getPageCmsLink, getContentCmsLink, } from "./state"; @@ -39,6 +41,8 @@ export class Push { // Clear failed content registry from any previous sync clearFailedContentRegistry(); + // PROD-2315: clear blocked-model registry (cross-kind collisions) from any previous sync + clearBlockedModelRegistry(); // Preflight (PROD-2203): start each run with a clean report of planned actions preflightReport.reset(); @@ -123,6 +127,7 @@ export class Push { contentID?: number; guid?: string; locale?: string; + blockedBy?: string; }> = []; results.forEach((result: PushResults) => { @@ -181,10 +186,16 @@ export class Push { console.log(ansiColors.red("⚠️ ERROR SUMMARY")); console.log(ansiColors.red("═".repeat(50))); + // PROD-2315: split out failures caused by an unresolved model reference-name collision so + // they can be grouped into one line per blocking model, instead of repeating the same cause + // across every dependent container/content/page (the "1 collision → 9 failures" cascade). + const blockedFailures = syncFailureDetails.filter((f) => f.blockedBy); + const normalFailures = syncFailureDetails.filter((f) => !f.blockedBy); + // Show sync failure details line by line with links - if (syncFailureDetails.length > 0) { - console.log(ansiColors.red(`\n Sync Failures (${syncFailureDetails.length}):`)); - syncFailureDetails.forEach(({ name, error, type, pageID, contentID, guid, locale }) => { + if (normalFailures.length > 0) { + console.log(ansiColors.red(`\n Sync Failures (${normalFailures.length}):`)); + normalFailures.forEach(({ name, error, type, pageID, contentID, guid, locale }) => { // Format: [guid][locale] • name: error const prefix = guid && locale ? `[${guid}][${locale}]` : guid ? `[${guid}]` : ""; console.log(ansiColors.red(` ${prefix} • ${name}: ${error}`)); @@ -204,11 +215,29 @@ export class Push { console.log(ansiColors.gray(` ${link}`)); } }); - } else if (totalSyncFailures > 0) { + } else if (totalSyncFailures > 0 && blockedFailures.length === 0) { // Fallback if no detailed failure info available console.log(ansiColors.red(` Sync: ${totalSyncFailures} items failed (see details above)`)); } + // PROD-2315: grouped view of everything blocked by an unresolved model collision — one line + // per model with the shared cause, then the affected items indented beneath it. + if (blockedFailures.length > 0) { + const byModel = new Map(); + blockedFailures.forEach((f) => { + const key = f.blockedBy as string; + const group = byModel.get(key) || []; + group.push(f); + byModel.set(key, group); + }); + console.log(ansiColors.red(`\n Blocked by model conflicts (${blockedFailures.length}):`)); + Array.from(byModel.entries()).forEach(([model, items]) => { + const reason = getBlockedModel(model)?.reason || "unresolved model reference-name conflict"; + console.log(ansiColors.red(` • ${model}: ${items.length} dependent item(s) not synced — ${reason}`)); + items.forEach((it) => console.log(ansiColors.gray(` - ${it.type || "container"}: ${it.name}`))); + }); + } + // Show detailed sync errors (operation-level) if (syncErrors.length > 0) { console.log(ansiColors.red(`\n Operation Errors:`)); diff --git a/src/core/state.ts b/src/core/state.ts index 793f2c4..7f5d062 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -86,7 +86,12 @@ export interface State { // Failed content registry - tracks content items that failed during sync // Used by page pusher to provide better error messages when content mappings are missing - failedContentRegistry: Map; + failedContentRegistry: Map; + + // PROD-2315: models that cannot be synced due to a cross-kind reference-name collision (a content + // and a component model sharing a name). Keyed by referenceName.toLowerCase(). Dependent + // containers/content/pages consult this to attribute their own skips/failures to the real cause. + blockedModelRegistry: Map; } // Global state - populated from argv and referenced throughout the app @@ -148,6 +153,9 @@ export const state: State = { // Failed content registry - tracks content items that failed during sync failedContentRegistry: new Map(), + + // PROD-2315: blocked-model registry (cross-kind reference-name collisions) + blockedModelRegistry: new Map(), }; /** @@ -688,8 +696,14 @@ export function clearLogger(): void { * @param error - The error message * @param locale - The locale being processed */ -export function registerFailedContent(contentID: number, referenceName: string, error: string, locale: string): void { - state.failedContentRegistry.set(contentID, { referenceName, error, locale }); +export function registerFailedContent( + contentID: number, + referenceName: string, + error: string, + locale: string, + blockedByModel?: string +): void { + state.failedContentRegistry.set(contentID, { referenceName, error, locale, blockedByModel }); } /** @@ -699,7 +713,7 @@ export function registerFailedContent(contentID: number, referenceName: string, */ export function getFailedContent( contentID: number -): { referenceName: string; error: string; locale: string } | undefined { +): { referenceName: string; error: string; locale: string; blockedByModel?: string } | undefined { return state.failedContentRegistry.get(contentID); } @@ -710,6 +724,35 @@ export function clearFailedContentRegistry(): void { state.failedContentRegistry.clear(); } +/** + * PROD-2315: register a model that cannot be synced due to a cross-kind reference-name collision. + * @param referenceName - The model's reference name (used as the key, case-insensitively) + * @param sourceModelID - The source model ID, so containers can look up by contentDefinitionID + * @param reason - The human-readable collision reason (surfaced on dependent failures) + */ +export function registerBlockedModel(referenceName: string, sourceModelID: number | undefined, reason: string): void { + state.blockedModelRegistry.set(referenceName.toLowerCase(), { referenceName, sourceModelID, reason }); +} + +/** Look up a blocked model by reference name (case-insensitive). */ +export function getBlockedModel( + referenceName: string +): { referenceName: string; sourceModelID?: number; reason: string } | undefined { + return state.blockedModelRegistry.get(referenceName.toLowerCase()); +} + +/** Look up a blocked model by its source model ID (used by the container pusher via contentDefinitionID). */ +export function getBlockedModelByID( + sourceModelID: number +): { referenceName: string; sourceModelID?: number; reason: string } | undefined { + return Array.from(state.blockedModelRegistry.values()).find((entry) => entry.sourceModelID === sourceModelID); +} + +/** Clear the blocked-model registry (should be called at the start of each sync). */ +export function clearBlockedModelRegistry(): void { + state.blockedModelRegistry.clear(); +} + /** * Get the CMS app URL based on environment * - dev/local/preprod: app-qa.publishwithagility.com diff --git a/src/core/tests/state.test.ts b/src/core/tests/state.test.ts index b1dc06f..a734727 100644 --- a/src/core/tests/state.test.ts +++ b/src/core/tests/state.test.ts @@ -13,6 +13,10 @@ import { registerFailedContent, getFailedContent, clearFailedContentRegistry, + registerBlockedModel, + getBlockedModel, + getBlockedModelByID, + clearBlockedModelRegistry, initializeLogger, initializeGuidLogger, getLoggerForGuid, @@ -324,6 +328,41 @@ describe("failed content registry", () => { clearFailedContentRegistry(); expect(getFailedContent(1)).toBeUndefined(); }); + + it("carries the blocking model when provided (PROD-2315)", () => { + registerFailedContent(7, "ref", "err", "en-us", "LinkCard"); + expect(getFailedContent(7)?.blockedByModel).toBe("LinkCard"); + }); +}); + +// ─── Blocked model registry (PROD-2315) ─────────────────────────────────────── + +describe("blocked model registry (PROD-2315)", () => { + it("registers and retrieves a blocked model by reference name (case-insensitive)", () => { + registerBlockedModel("LinkCard", 182, "cross-kind conflict"); + expect(getBlockedModel("linkcard")).toEqual({ + referenceName: "LinkCard", + sourceModelID: 182, + reason: "cross-kind conflict", + }); + }); + + it("retrieves a blocked model by its source model ID", () => { + registerBlockedModel("LinkCard", 182, "cross-kind conflict"); + expect(getBlockedModelByID(182)?.referenceName).toBe("LinkCard"); + }); + + it("returns undefined for an unknown name or ID", () => { + expect(getBlockedModel("nope")).toBeUndefined(); + expect(getBlockedModelByID(9999)).toBeUndefined(); + }); + + it("clears the registry", () => { + registerBlockedModel("LinkCard", 182, "cross-kind conflict"); + clearBlockedModelRegistry(); + expect(getBlockedModel("LinkCard")).toBeUndefined(); + expect(getBlockedModelByID(182)).toBeUndefined(); + }); }); // ─── Logger factory functions ───────────────────────────────────────────────── diff --git a/src/lib/pushers/container-pusher.ts b/src/lib/pushers/container-pusher.ts index 7934c20..795c653 100644 --- a/src/lib/pushers/container-pusher.ts +++ b/src/lib/pushers/container-pusher.ts @@ -1,6 +1,6 @@ import * as mgmtApi from "@agility/management-sdk"; import { ApiClient } from "@agility/management-sdk"; -import { getLoggerForGuid, state } from "core/state"; +import { getLoggerForGuid, state, getBlockedModelByID } from "core/state"; import { ContainerMapper } from "lib/mappers/container-mapper"; import { ModelMapper } from "lib/mappers/model-mapper"; import { Logs } from "core/logs"; @@ -37,6 +37,32 @@ export async function pushContainers( const containerMapper = new ContainerMapper(sourceGuid[0], targetGuid[0]); const modelMapper = new ModelMapper(sourceGuid[0], targetGuid[0]); + // PROD-2315 (Tier 2): a container whose model mapping is missing is normally logged as a generic + // "Target model mapping not found". If the model is missing because of a cross-kind reference-name + // collision, attribute the skip to that collision (and surface it in the grouped ERROR SUMMARY). + const recordModelMappingSkip = (container: mgmtApi.Container) => { + const blocked = getBlockedModelByID(container.contentDefinitionID); + if (blocked) { + const detail = `blocked by model conflict "${blocked.referenceName}": ${blocked.reason}`; + logger.container.skipped(container, detail, targetGuid[0]); + preflightReport.record({ phase: "Containers", action: "skip", name: container.referenceName, detail }); + failureDetails.push({ + name: container.referenceName, + error: detail, + guid: sourceGuid[0], + blockedBy: blocked.referenceName, + }); + } else { + logger.container.skipped(container, "Target model mapping not found", targetGuid[0]); + preflightReport.record({ + phase: "Containers", + action: "skip", + name: container.referenceName, + detail: "target model mapping not found", + }); + } + }; + for (const sourceContainer of sourceContainers) { //SPECIAL CASE for fixed Agility containers if ( @@ -114,13 +140,7 @@ export async function pushContainers( } if (targetModelID < 1) { - logger.container.skipped(sourceContainer, "Target model mapping not found", targetGuid[0]); - preflightReport.record({ - phase: "Containers", - action: "skip", - name: sourceContainer.referenceName, - detail: "target model mapping not found", - }); + recordModelMappingSkip(sourceContainer); skipped++; } else if (shouldSkip) { // Container exists and is up to date - skip @@ -187,13 +207,7 @@ export async function pushContainers( if (shouldCreate) { // Container doesn't exist - create new one if (targetModelID < 1) { - logger.container.skipped(sourceContainer, "Target model mapping not found", targetGuid[0]); - preflightReport.record({ - phase: "Containers", - action: "skip", - name: sourceContainer.referenceName, - detail: "target model mapping not found", - }); + recordModelMappingSkip(sourceContainer); skipped++; } else if (state.preflight) { // Preflight: would create, but skip the write. diff --git a/src/lib/pushers/content-pusher/content-pusher.ts b/src/lib/pushers/content-pusher/content-pusher.ts index adc91ff..3bae3eb 100644 --- a/src/lib/pushers/content-pusher/content-pusher.ts +++ b/src/lib/pushers/content-pusher/content-pusher.ts @@ -1,7 +1,7 @@ // Removed finder imports - using mapper directly import ansiColors from "ansi-colors"; // Removed ContentBatchProcessor import - individual pusher only handles individual processing -import { getLoggerForGuid, state, registerFailedContent } from "core/state"; +import { getLoggerForGuid, state, registerFailedContent, getBlockedModel } from "core/state"; import { ContentItemMapper } from "lib/mappers/content-item-mapper"; import { filterContentItemsForProcessing } from "./util/filter-content-items-for-processing"; import { getContentItemTypes } from "./util/get-content-item-types"; @@ -51,6 +51,7 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content contentID?: number; guid?: string; locale?: string; + blockedBy?: string; }> = []; try { @@ -59,6 +60,27 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content // Account for pre-classification skips (missing mappings) if (skippedItems && skippedItems.length > 0) { totalSkipped += skippedItems.length; + + // PROD-2315 (Tier 2): if an item was skipped because its model is blocked by a cross-kind + // reference-name collision, attribute it to that collision. Registering it in the failed-content + // registry makes any page that references it inherit the real cause (process-page consults the + // registry), and the tagged failure detail lets the ERROR SUMMARY group these under one line. + for (const item of skippedItems) { + const blocked = getBlockedModel(item.properties?.definitionName || ""); + if (!blocked) continue; + const name = item.properties?.referenceName || String(item.contentID); + const error = `blocked by model conflict "${blocked.referenceName}": ${blocked.reason}`; + registerFailedContent(item.contentID, name, error, locale, blocked.referenceName); + allFailureDetails.push({ + name, + error, + type: "content", + contentID: item.contentID, + guid: sourceGuidStr, + locale, + blockedBy: blocked.referenceName, + }); + } } // Process linked content items second (with dependencies) diff --git a/src/lib/pushers/model-pusher.ts b/src/lib/pushers/model-pusher.ts index b71f29c..56d96bc 100644 --- a/src/lib/pushers/model-pusher.ts +++ b/src/lib/pushers/model-pusher.ts @@ -1,5 +1,5 @@ import * as mgmtApi from "@agility/management-sdk"; -import { getApiClient, state, getLoggerForGuid } from "../../core/state"; +import { getApiClient, state, getLoggerForGuid, registerBlockedModel } from "../../core/state"; import { PusherResult, FailureDetail } from "../../types/sourceData"; import { ModelMapper } from "lib/mappers/model-mapper"; import { Logs } from "core/logs"; @@ -31,7 +31,7 @@ function modelKindName(model: mgmtApi.Model): string { * sides but as different kinds (content vs component/module). Agility forbids content and component * models from sharing a reference name, so this can never be created; the user must reconcile it. */ -function crossKindCollisionMessage(source: mgmtApi.Model, target: mgmtApi.Model): string { +function crossModelTypeCollisionMessage(source: mgmtApi.Model, target: mgmtApi.Model): string { return ( `Model "${source.referenceName}" is a ${modelKindName(source)} model on the source, but a ` + `${modelKindName(target)} model with that reference name already exists on the target. ` + @@ -103,7 +103,7 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp let shouldSkip: { model: mgmtApi.Model; reason: string }[] = []; let stubCreated = []; // PROD-2315: same reference name on both sides but a different kind (content vs component). - const crossKindConflicts: { model: mgmtApi.Model; target: mgmtApi.Model }[] = []; + const crossModelTypeConflicts: { model: mgmtApi.Model; target: mgmtApi.Model }[] = []; // PROD-2250: process source models that already have a mapping before brand-new // (unmapped) models. Reconciling existing target models first — using their known @@ -207,14 +207,14 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp // targetModelByReference, so it would otherwise fall through to "create" and hit a guaranteed // server 409 (Agility forbids content + component models sharing a name). Because // targetModelByReference used modelTypeMatches — which returns true when either side's type is - // unknown — crossKindTarget is non-null ONLY when both types are known and differ. Type-unknown + // unknown — crossModelTypeTarget is non-null ONLY when both types are known and differ. Type-unknown // pulls are unaffected and keep today's behavior. - const crossKindTarget = + const crossModelTypeTarget = !sourceMapping && !targetModelByReference ? targetData.find((t) => t.referenceName === sourceModel.referenceName) || null : null; - if (crossKindTarget) { - crossKindConflicts.push({ model: sourceModel, target: crossKindTarget }); + if (crossModelTypeTarget) { + crossModelTypeConflicts.push({ model: sourceModel, target: crossModelTypeTarget }); continue; } @@ -278,12 +278,12 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp }); } // PROD-2315: preview cross-kind collisions as conflicts (a real run would fail them). - for (const { model, target } of crossKindConflicts) { + for (const { model, target } of crossModelTypeConflicts) { preflightReport.record({ phase: "Models", action: "conflict", name: model.referenceName, - detail: crossKindCollisionMessage(model, target), + detail: crossModelTypeCollisionMessage(model, target), }); } return { @@ -348,11 +348,14 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp // PROD-2315: cross-kind reference-name collisions can never be created (Agility forbids content and // component models sharing a name). Fail them with an actionable message that reaches the ERROR // SUMMARY, rather than attempting a create that is guaranteed to 409. - for (const { model, target } of crossKindConflicts) { - const message = crossKindCollisionMessage(model, target); + for (const { model, target } of crossModelTypeConflicts) { + const message = crossModelTypeCollisionMessage(model, target); logger.model.error(model, new Error(message), targetGuid[0]); failed++; failureDetails.push({ name: model.referenceName, error: message, guid: sourceGuid[0] }); + // PROD-2315 (Tier 2): record the block so dependent containers/content/pages can attribute + // their own skips/failures to this collision instead of a generic "mapping not found". + registerBlockedModel(model.referenceName, model.id, message); } return { diff --git a/src/lib/pushers/page-pusher/process-page.ts b/src/lib/pushers/page-pusher/process-page.ts index 5dcb6f0..d68d4cf 100644 --- a/src/lib/pushers/page-pusher/process-page.ts +++ b/src/lib/pushers/page-pusher/process-page.ts @@ -24,7 +24,13 @@ 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-2315: reference name of a model whose cross-kind collision blocked the referenced content. + blockedBy?: string; +}; export async function processPage({ channel, @@ -195,6 +201,7 @@ export async function processPage({ // Track first missing content mapping for error summary let firstMissingContentError: string | null = null; let firstMissingContentID: number | null = null; + let firstBlockedByModel: string | null = null; for (const [zoneName, zoneModules] of Object.entries(mappedZones)) { const newZoneContent = []; @@ -255,6 +262,9 @@ export async function processPage({ if (!firstMissingContentError) { firstMissingContentError = mappingError; firstMissingContentID = sourceContentId; + // PROD-2315: if the referenced content failed because its model is blocked, carry + // the blocking model up so the page failure can be grouped in the ERROR SUMMARY. + firstBlockedByModel = failedContent?.blockedByModel || null; } } } else { @@ -304,6 +314,7 @@ export async function processPage({ status: "failure", error: firstMissingContentError || `${missingMappings} missing content mappings`, contentID: firstMissingContentID || undefined, + blockedBy: firstBlockedByModel || undefined, }; } } diff --git a/src/lib/pushers/page-pusher/process-sitemap.ts b/src/lib/pushers/page-pusher/process-sitemap.ts index 5ce0815..591ec3f 100644 --- a/src/lib/pushers/page-pusher/process-sitemap.ts +++ b/src/lib/pushers/page-pusher/process-sitemap.ts @@ -21,6 +21,7 @@ interface ReturnType { contentID?: number; guid?: string; locale?: string; + blockedBy?: string; // PROD-2315: model collision that blocked this page's content }>; } @@ -155,6 +156,7 @@ export async function processSitemap({ contentID: pageRes.contentID, // Include contentID for linking to the missing content guid: sourceGuid, locale, + blockedBy: pageRes.blockedBy, // PROD-2315: group page failures caused by a model collision }); } diff --git a/src/lib/pushers/tests/model-pusher.test.ts b/src/lib/pushers/tests/model-pusher.test.ts index ad0b069..a07227b 100644 --- a/src/lib/pushers/tests/model-pusher.test.ts +++ b/src/lib/pushers/tests/model-pusher.test.ts @@ -487,6 +487,7 @@ describe("pushModels — mapped-before-unmapped ordering (PROD-2250)", () => { describe("pushModels — cross-kind reference-name collision (PROD-2315)", () => { it("fails a same-name model of a different kind with an actionable message instead of creating it", async () => { + stateModule.clearBlockedModelRegistry(); const saveModel = jest.fn(); jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel)); @@ -507,6 +508,11 @@ describe("pushModels — cross-kind reference-name collision (PROD-2315)", () => expect(result.failureDetails![0].name).toBe("LinkCard"); expect(result.failureDetails![0].error).toMatch(/component\/module model on the source/); expect(result.failureDetails![0].error).toMatch(/content model with that reference name already exists/); + + // PROD-2315 (Tier 2): the block is registered so dependents can attribute their own failures. + const blocked = stateModule.getBlockedModel("LinkCard"); + expect(blocked).toBeDefined(); + expect(blocked!.sourceModelID).toBe(sourceModel.id); }); it("does NOT flag a same-name model when the kinds are unknown (falls back to adopt-by-reference)", async () => { diff --git a/src/types/sourceData.ts b/src/types/sourceData.ts index e927883..e760c0b 100644 --- a/src/types/sourceData.ts +++ b/src/types/sourceData.ts @@ -41,6 +41,9 @@ export interface FailureDetail { contentID?: number; // Source content ID for content links guid?: string; // Source instance GUID locale?: string; // Locale code + // PROD-2315: reference name of a model whose cross-kind collision blocked this entity. Dependents + // tagged with the same value are collapsed into one line in the ERROR SUMMARY. + blockedBy?: string; } /** From 22f8c90faffd8b9cf50a9dc4b04ba36a3deedb20 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 15 Jul 2026 14:44:21 -0400 Subject: [PATCH 3/5] PROD-2315: label content models correctly in the collision message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live verification surfaced that content models report contentDefinitionTypeID 0 (content item) — the ticket assumed 1. modelKindName only mapped 1 -> "content", so the message read "is a type 0 model on the source" instead of "content". Map both 0 (item) and 1 (list) to "content"; 2 stays "component/module". Verified end-to-end (source content LinkCard -> target component LinkCard): model push reports 1 failed with "…is a content model on the source, but a component/module model…"; the container + 3 content items are collapsed into a single "Blocked by model conflicts (4)" summary line — no raw 409, no cascade. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/pushers/model-pusher.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/pushers/model-pusher.ts b/src/lib/pushers/model-pusher.ts index 56d96bc..12c1318 100644 --- a/src/lib/pushers/model-pusher.ts +++ b/src/lib/pushers/model-pusher.ts @@ -20,10 +20,15 @@ function modelTypeMatches(a: mgmtApi.Model, b: mgmtApi.Model): boolean { return aType === bType; } -/** Human-readable model kind for messages. 1 = content, 2 = component/module. */ +/** + * Human-readable model kind for messages. contentDefinitionTypeID: 2 = component/module; content + * models report 0 (content item) or 1 (content list) — both are "content" for the user's purposes. + */ function modelKindName(model: mgmtApi.Model): string { const t = (model as any)?.contentDefinitionTypeID; - return t === 1 ? "content" : t === 2 ? "component/module" : `type ${t}`; + if (t === 2) return "component/module"; + if (t === 0 || t === 1) return "content"; + return `type ${t}`; } /** From 74ebbd5269238f28d7590cbce144ba7085647bac Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Jul 2026 10:53:33 -0400 Subject: [PATCH 4/5] Revert "PROD-2315: label content models correctly in the collision message" This reverts commit 22f8c90faffd8b9cf50a9dc4b04ba36a3deedb20. --- src/lib/pushers/model-pusher.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/lib/pushers/model-pusher.ts b/src/lib/pushers/model-pusher.ts index 12c1318..56d96bc 100644 --- a/src/lib/pushers/model-pusher.ts +++ b/src/lib/pushers/model-pusher.ts @@ -20,15 +20,10 @@ function modelTypeMatches(a: mgmtApi.Model, b: mgmtApi.Model): boolean { return aType === bType; } -/** - * Human-readable model kind for messages. contentDefinitionTypeID: 2 = component/module; content - * models report 0 (content item) or 1 (content list) — both are "content" for the user's purposes. - */ +/** Human-readable model kind for messages. 1 = content, 2 = component/module. */ function modelKindName(model: mgmtApi.Model): string { const t = (model as any)?.contentDefinitionTypeID; - if (t === 2) return "component/module"; - if (t === 0 || t === 1) return "content"; - return `type ${t}`; + return t === 1 ? "content" : t === 2 ? "component/module" : `type ${t}`; } /** From 87dbb72673dadb12e2218a6bafc6677073179f40 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Jul 2026 10:53:42 -0400 Subject: [PATCH 5/5] Revert "PROD-2315 (Tier 2): attribute + group the cross-kind collision cascade" This reverts commit 4a0065a3055f38ee3c616f8617aa00a004ebae81. --- src/core/push.ts | 37 ++------------ src/core/state.ts | 51 ++----------------- src/core/tests/state.test.ts | 39 -------------- src/lib/pushers/container-pusher.ts | 44 ++++++---------- .../pushers/content-pusher/content-pusher.ts | 24 +-------- src/lib/pushers/model-pusher.ts | 25 ++++----- src/lib/pushers/page-pusher/process-page.ts | 13 +---- .../pushers/page-pusher/process-sitemap.ts | 2 - src/lib/pushers/tests/model-pusher.test.ts | 6 --- src/types/sourceData.ts | 3 -- 10 files changed, 36 insertions(+), 208 deletions(-) diff --git a/src/core/push.ts b/src/core/push.ts index 26bd6e1..f310bf2 100644 --- a/src/core/push.ts +++ b/src/core/push.ts @@ -4,8 +4,6 @@ import { getLogger, state, clearFailedContentRegistry, - clearBlockedModelRegistry, - getBlockedModel, getPageCmsLink, getContentCmsLink, } from "./state"; @@ -41,8 +39,6 @@ export class Push { // Clear failed content registry from any previous sync clearFailedContentRegistry(); - // PROD-2315: clear blocked-model registry (cross-kind collisions) from any previous sync - clearBlockedModelRegistry(); // Preflight (PROD-2203): start each run with a clean report of planned actions preflightReport.reset(); @@ -127,7 +123,6 @@ export class Push { contentID?: number; guid?: string; locale?: string; - blockedBy?: string; }> = []; results.forEach((result: PushResults) => { @@ -186,16 +181,10 @@ export class Push { console.log(ansiColors.red("⚠️ ERROR SUMMARY")); console.log(ansiColors.red("═".repeat(50))); - // PROD-2315: split out failures caused by an unresolved model reference-name collision so - // they can be grouped into one line per blocking model, instead of repeating the same cause - // across every dependent container/content/page (the "1 collision → 9 failures" cascade). - const blockedFailures = syncFailureDetails.filter((f) => f.blockedBy); - const normalFailures = syncFailureDetails.filter((f) => !f.blockedBy); - // Show sync failure details line by line with links - if (normalFailures.length > 0) { - console.log(ansiColors.red(`\n Sync Failures (${normalFailures.length}):`)); - normalFailures.forEach(({ name, error, type, pageID, contentID, guid, locale }) => { + if (syncFailureDetails.length > 0) { + console.log(ansiColors.red(`\n Sync Failures (${syncFailureDetails.length}):`)); + syncFailureDetails.forEach(({ name, error, type, pageID, contentID, guid, locale }) => { // Format: [guid][locale] • name: error const prefix = guid && locale ? `[${guid}][${locale}]` : guid ? `[${guid}]` : ""; console.log(ansiColors.red(` ${prefix} • ${name}: ${error}`)); @@ -215,29 +204,11 @@ export class Push { console.log(ansiColors.gray(` ${link}`)); } }); - } else if (totalSyncFailures > 0 && blockedFailures.length === 0) { + } else if (totalSyncFailures > 0) { // Fallback if no detailed failure info available console.log(ansiColors.red(` Sync: ${totalSyncFailures} items failed (see details above)`)); } - // PROD-2315: grouped view of everything blocked by an unresolved model collision — one line - // per model with the shared cause, then the affected items indented beneath it. - if (blockedFailures.length > 0) { - const byModel = new Map(); - blockedFailures.forEach((f) => { - const key = f.blockedBy as string; - const group = byModel.get(key) || []; - group.push(f); - byModel.set(key, group); - }); - console.log(ansiColors.red(`\n Blocked by model conflicts (${blockedFailures.length}):`)); - Array.from(byModel.entries()).forEach(([model, items]) => { - const reason = getBlockedModel(model)?.reason || "unresolved model reference-name conflict"; - console.log(ansiColors.red(` • ${model}: ${items.length} dependent item(s) not synced — ${reason}`)); - items.forEach((it) => console.log(ansiColors.gray(` - ${it.type || "container"}: ${it.name}`))); - }); - } - // Show detailed sync errors (operation-level) if (syncErrors.length > 0) { console.log(ansiColors.red(`\n Operation Errors:`)); diff --git a/src/core/state.ts b/src/core/state.ts index 7f5d062..793f2c4 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -86,12 +86,7 @@ export interface State { // Failed content registry - tracks content items that failed during sync // Used by page pusher to provide better error messages when content mappings are missing - failedContentRegistry: Map; - - // PROD-2315: models that cannot be synced due to a cross-kind reference-name collision (a content - // and a component model sharing a name). Keyed by referenceName.toLowerCase(). Dependent - // containers/content/pages consult this to attribute their own skips/failures to the real cause. - blockedModelRegistry: Map; + failedContentRegistry: Map; } // Global state - populated from argv and referenced throughout the app @@ -153,9 +148,6 @@ export const state: State = { // Failed content registry - tracks content items that failed during sync failedContentRegistry: new Map(), - - // PROD-2315: blocked-model registry (cross-kind reference-name collisions) - blockedModelRegistry: new Map(), }; /** @@ -696,14 +688,8 @@ export function clearLogger(): void { * @param error - The error message * @param locale - The locale being processed */ -export function registerFailedContent( - contentID: number, - referenceName: string, - error: string, - locale: string, - blockedByModel?: string -): void { - state.failedContentRegistry.set(contentID, { referenceName, error, locale, blockedByModel }); +export function registerFailedContent(contentID: number, referenceName: string, error: string, locale: string): void { + state.failedContentRegistry.set(contentID, { referenceName, error, locale }); } /** @@ -713,7 +699,7 @@ export function registerFailedContent( */ export function getFailedContent( contentID: number -): { referenceName: string; error: string; locale: string; blockedByModel?: string } | undefined { +): { referenceName: string; error: string; locale: string } | undefined { return state.failedContentRegistry.get(contentID); } @@ -724,35 +710,6 @@ export function clearFailedContentRegistry(): void { state.failedContentRegistry.clear(); } -/** - * PROD-2315: register a model that cannot be synced due to a cross-kind reference-name collision. - * @param referenceName - The model's reference name (used as the key, case-insensitively) - * @param sourceModelID - The source model ID, so containers can look up by contentDefinitionID - * @param reason - The human-readable collision reason (surfaced on dependent failures) - */ -export function registerBlockedModel(referenceName: string, sourceModelID: number | undefined, reason: string): void { - state.blockedModelRegistry.set(referenceName.toLowerCase(), { referenceName, sourceModelID, reason }); -} - -/** Look up a blocked model by reference name (case-insensitive). */ -export function getBlockedModel( - referenceName: string -): { referenceName: string; sourceModelID?: number; reason: string } | undefined { - return state.blockedModelRegistry.get(referenceName.toLowerCase()); -} - -/** Look up a blocked model by its source model ID (used by the container pusher via contentDefinitionID). */ -export function getBlockedModelByID( - sourceModelID: number -): { referenceName: string; sourceModelID?: number; reason: string } | undefined { - return Array.from(state.blockedModelRegistry.values()).find((entry) => entry.sourceModelID === sourceModelID); -} - -/** Clear the blocked-model registry (should be called at the start of each sync). */ -export function clearBlockedModelRegistry(): void { - state.blockedModelRegistry.clear(); -} - /** * Get the CMS app URL based on environment * - dev/local/preprod: app-qa.publishwithagility.com diff --git a/src/core/tests/state.test.ts b/src/core/tests/state.test.ts index a734727..b1dc06f 100644 --- a/src/core/tests/state.test.ts +++ b/src/core/tests/state.test.ts @@ -13,10 +13,6 @@ import { registerFailedContent, getFailedContent, clearFailedContentRegistry, - registerBlockedModel, - getBlockedModel, - getBlockedModelByID, - clearBlockedModelRegistry, initializeLogger, initializeGuidLogger, getLoggerForGuid, @@ -328,41 +324,6 @@ describe("failed content registry", () => { clearFailedContentRegistry(); expect(getFailedContent(1)).toBeUndefined(); }); - - it("carries the blocking model when provided (PROD-2315)", () => { - registerFailedContent(7, "ref", "err", "en-us", "LinkCard"); - expect(getFailedContent(7)?.blockedByModel).toBe("LinkCard"); - }); -}); - -// ─── Blocked model registry (PROD-2315) ─────────────────────────────────────── - -describe("blocked model registry (PROD-2315)", () => { - it("registers and retrieves a blocked model by reference name (case-insensitive)", () => { - registerBlockedModel("LinkCard", 182, "cross-kind conflict"); - expect(getBlockedModel("linkcard")).toEqual({ - referenceName: "LinkCard", - sourceModelID: 182, - reason: "cross-kind conflict", - }); - }); - - it("retrieves a blocked model by its source model ID", () => { - registerBlockedModel("LinkCard", 182, "cross-kind conflict"); - expect(getBlockedModelByID(182)?.referenceName).toBe("LinkCard"); - }); - - it("returns undefined for an unknown name or ID", () => { - expect(getBlockedModel("nope")).toBeUndefined(); - expect(getBlockedModelByID(9999)).toBeUndefined(); - }); - - it("clears the registry", () => { - registerBlockedModel("LinkCard", 182, "cross-kind conflict"); - clearBlockedModelRegistry(); - expect(getBlockedModel("LinkCard")).toBeUndefined(); - expect(getBlockedModelByID(182)).toBeUndefined(); - }); }); // ─── Logger factory functions ───────────────────────────────────────────────── diff --git a/src/lib/pushers/container-pusher.ts b/src/lib/pushers/container-pusher.ts index 795c653..7934c20 100644 --- a/src/lib/pushers/container-pusher.ts +++ b/src/lib/pushers/container-pusher.ts @@ -1,6 +1,6 @@ import * as mgmtApi from "@agility/management-sdk"; import { ApiClient } from "@agility/management-sdk"; -import { getLoggerForGuid, state, getBlockedModelByID } from "core/state"; +import { getLoggerForGuid, state } from "core/state"; import { ContainerMapper } from "lib/mappers/container-mapper"; import { ModelMapper } from "lib/mappers/model-mapper"; import { Logs } from "core/logs"; @@ -37,32 +37,6 @@ export async function pushContainers( const containerMapper = new ContainerMapper(sourceGuid[0], targetGuid[0]); const modelMapper = new ModelMapper(sourceGuid[0], targetGuid[0]); - // PROD-2315 (Tier 2): a container whose model mapping is missing is normally logged as a generic - // "Target model mapping not found". If the model is missing because of a cross-kind reference-name - // collision, attribute the skip to that collision (and surface it in the grouped ERROR SUMMARY). - const recordModelMappingSkip = (container: mgmtApi.Container) => { - const blocked = getBlockedModelByID(container.contentDefinitionID); - if (blocked) { - const detail = `blocked by model conflict "${blocked.referenceName}": ${blocked.reason}`; - logger.container.skipped(container, detail, targetGuid[0]); - preflightReport.record({ phase: "Containers", action: "skip", name: container.referenceName, detail }); - failureDetails.push({ - name: container.referenceName, - error: detail, - guid: sourceGuid[0], - blockedBy: blocked.referenceName, - }); - } else { - logger.container.skipped(container, "Target model mapping not found", targetGuid[0]); - preflightReport.record({ - phase: "Containers", - action: "skip", - name: container.referenceName, - detail: "target model mapping not found", - }); - } - }; - for (const sourceContainer of sourceContainers) { //SPECIAL CASE for fixed Agility containers if ( @@ -140,7 +114,13 @@ export async function pushContainers( } if (targetModelID < 1) { - recordModelMappingSkip(sourceContainer); + logger.container.skipped(sourceContainer, "Target model mapping not found", targetGuid[0]); + preflightReport.record({ + phase: "Containers", + action: "skip", + name: sourceContainer.referenceName, + detail: "target model mapping not found", + }); skipped++; } else if (shouldSkip) { // Container exists and is up to date - skip @@ -207,7 +187,13 @@ export async function pushContainers( if (shouldCreate) { // Container doesn't exist - create new one if (targetModelID < 1) { - recordModelMappingSkip(sourceContainer); + logger.container.skipped(sourceContainer, "Target model mapping not found", targetGuid[0]); + preflightReport.record({ + phase: "Containers", + action: "skip", + name: sourceContainer.referenceName, + detail: "target model mapping not found", + }); skipped++; } else if (state.preflight) { // Preflight: would create, but skip the write. diff --git a/src/lib/pushers/content-pusher/content-pusher.ts b/src/lib/pushers/content-pusher/content-pusher.ts index 3bae3eb..adc91ff 100644 --- a/src/lib/pushers/content-pusher/content-pusher.ts +++ b/src/lib/pushers/content-pusher/content-pusher.ts @@ -1,7 +1,7 @@ // Removed finder imports - using mapper directly import ansiColors from "ansi-colors"; // Removed ContentBatchProcessor import - individual pusher only handles individual processing -import { getLoggerForGuid, state, registerFailedContent, getBlockedModel } from "core/state"; +import { getLoggerForGuid, state, registerFailedContent } from "core/state"; import { ContentItemMapper } from "lib/mappers/content-item-mapper"; import { filterContentItemsForProcessing } from "./util/filter-content-items-for-processing"; import { getContentItemTypes } from "./util/get-content-item-types"; @@ -51,7 +51,6 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content contentID?: number; guid?: string; locale?: string; - blockedBy?: string; }> = []; try { @@ -60,27 +59,6 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content // Account for pre-classification skips (missing mappings) if (skippedItems && skippedItems.length > 0) { totalSkipped += skippedItems.length; - - // PROD-2315 (Tier 2): if an item was skipped because its model is blocked by a cross-kind - // reference-name collision, attribute it to that collision. Registering it in the failed-content - // registry makes any page that references it inherit the real cause (process-page consults the - // registry), and the tagged failure detail lets the ERROR SUMMARY group these under one line. - for (const item of skippedItems) { - const blocked = getBlockedModel(item.properties?.definitionName || ""); - if (!blocked) continue; - const name = item.properties?.referenceName || String(item.contentID); - const error = `blocked by model conflict "${blocked.referenceName}": ${blocked.reason}`; - registerFailedContent(item.contentID, name, error, locale, blocked.referenceName); - allFailureDetails.push({ - name, - error, - type: "content", - contentID: item.contentID, - guid: sourceGuidStr, - locale, - blockedBy: blocked.referenceName, - }); - } } // Process linked content items second (with dependencies) diff --git a/src/lib/pushers/model-pusher.ts b/src/lib/pushers/model-pusher.ts index 56d96bc..b71f29c 100644 --- a/src/lib/pushers/model-pusher.ts +++ b/src/lib/pushers/model-pusher.ts @@ -1,5 +1,5 @@ import * as mgmtApi from "@agility/management-sdk"; -import { getApiClient, state, getLoggerForGuid, registerBlockedModel } from "../../core/state"; +import { getApiClient, state, getLoggerForGuid } from "../../core/state"; import { PusherResult, FailureDetail } from "../../types/sourceData"; import { ModelMapper } from "lib/mappers/model-mapper"; import { Logs } from "core/logs"; @@ -31,7 +31,7 @@ function modelKindName(model: mgmtApi.Model): string { * sides but as different kinds (content vs component/module). Agility forbids content and component * models from sharing a reference name, so this can never be created; the user must reconcile it. */ -function crossModelTypeCollisionMessage(source: mgmtApi.Model, target: mgmtApi.Model): string { +function crossKindCollisionMessage(source: mgmtApi.Model, target: mgmtApi.Model): string { return ( `Model "${source.referenceName}" is a ${modelKindName(source)} model on the source, but a ` + `${modelKindName(target)} model with that reference name already exists on the target. ` + @@ -103,7 +103,7 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp let shouldSkip: { model: mgmtApi.Model; reason: string }[] = []; let stubCreated = []; // PROD-2315: same reference name on both sides but a different kind (content vs component). - const crossModelTypeConflicts: { model: mgmtApi.Model; target: mgmtApi.Model }[] = []; + const crossKindConflicts: { model: mgmtApi.Model; target: mgmtApi.Model }[] = []; // PROD-2250: process source models that already have a mapping before brand-new // (unmapped) models. Reconciling existing target models first — using their known @@ -207,14 +207,14 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp // targetModelByReference, so it would otherwise fall through to "create" and hit a guaranteed // server 409 (Agility forbids content + component models sharing a name). Because // targetModelByReference used modelTypeMatches — which returns true when either side's type is - // unknown — crossModelTypeTarget is non-null ONLY when both types are known and differ. Type-unknown + // unknown — crossKindTarget is non-null ONLY when both types are known and differ. Type-unknown // pulls are unaffected and keep today's behavior. - const crossModelTypeTarget = + const crossKindTarget = !sourceMapping && !targetModelByReference ? targetData.find((t) => t.referenceName === sourceModel.referenceName) || null : null; - if (crossModelTypeTarget) { - crossModelTypeConflicts.push({ model: sourceModel, target: crossModelTypeTarget }); + if (crossKindTarget) { + crossKindConflicts.push({ model: sourceModel, target: crossKindTarget }); continue; } @@ -278,12 +278,12 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp }); } // PROD-2315: preview cross-kind collisions as conflicts (a real run would fail them). - for (const { model, target } of crossModelTypeConflicts) { + for (const { model, target } of crossKindConflicts) { preflightReport.record({ phase: "Models", action: "conflict", name: model.referenceName, - detail: crossModelTypeCollisionMessage(model, target), + detail: crossKindCollisionMessage(model, target), }); } return { @@ -348,14 +348,11 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp // PROD-2315: cross-kind reference-name collisions can never be created (Agility forbids content and // component models sharing a name). Fail them with an actionable message that reaches the ERROR // SUMMARY, rather than attempting a create that is guaranteed to 409. - for (const { model, target } of crossModelTypeConflicts) { - const message = crossModelTypeCollisionMessage(model, target); + for (const { model, target } of crossKindConflicts) { + const message = crossKindCollisionMessage(model, target); logger.model.error(model, new Error(message), targetGuid[0]); failed++; failureDetails.push({ name: model.referenceName, error: message, guid: sourceGuid[0] }); - // PROD-2315 (Tier 2): record the block so dependent containers/content/pages can attribute - // their own skips/failures to this collision instead of a generic "mapping not found". - registerBlockedModel(model.referenceName, model.id, message); } return { diff --git a/src/lib/pushers/page-pusher/process-page.ts b/src/lib/pushers/page-pusher/process-page.ts index d68d4cf..5dcb6f0 100644 --- a/src/lib/pushers/page-pusher/process-page.ts +++ b/src/lib/pushers/page-pusher/process-page.ts @@ -24,13 +24,7 @@ interface Props { logger: Logs; } -export type PageProcessResult = { - status: "success" | "skip" | "failure"; - error?: string; - contentID?: number; - // PROD-2315: reference name of a model whose cross-kind collision blocked the referenced content. - blockedBy?: string; -}; +export type PageProcessResult = { status: "success" | "skip" | "failure"; error?: string; contentID?: number }; export async function processPage({ channel, @@ -201,7 +195,6 @@ export async function processPage({ // Track first missing content mapping for error summary let firstMissingContentError: string | null = null; let firstMissingContentID: number | null = null; - let firstBlockedByModel: string | null = null; for (const [zoneName, zoneModules] of Object.entries(mappedZones)) { const newZoneContent = []; @@ -262,9 +255,6 @@ export async function processPage({ if (!firstMissingContentError) { firstMissingContentError = mappingError; firstMissingContentID = sourceContentId; - // PROD-2315: if the referenced content failed because its model is blocked, carry - // the blocking model up so the page failure can be grouped in the ERROR SUMMARY. - firstBlockedByModel = failedContent?.blockedByModel || null; } } } else { @@ -314,7 +304,6 @@ export async function processPage({ status: "failure", error: firstMissingContentError || `${missingMappings} missing content mappings`, contentID: firstMissingContentID || undefined, - blockedBy: firstBlockedByModel || undefined, }; } } diff --git a/src/lib/pushers/page-pusher/process-sitemap.ts b/src/lib/pushers/page-pusher/process-sitemap.ts index 591ec3f..5ce0815 100644 --- a/src/lib/pushers/page-pusher/process-sitemap.ts +++ b/src/lib/pushers/page-pusher/process-sitemap.ts @@ -21,7 +21,6 @@ interface ReturnType { contentID?: number; guid?: string; locale?: string; - blockedBy?: string; // PROD-2315: model collision that blocked this page's content }>; } @@ -156,7 +155,6 @@ export async function processSitemap({ contentID: pageRes.contentID, // Include contentID for linking to the missing content guid: sourceGuid, locale, - blockedBy: pageRes.blockedBy, // PROD-2315: group page failures caused by a model collision }); } diff --git a/src/lib/pushers/tests/model-pusher.test.ts b/src/lib/pushers/tests/model-pusher.test.ts index a07227b..ad0b069 100644 --- a/src/lib/pushers/tests/model-pusher.test.ts +++ b/src/lib/pushers/tests/model-pusher.test.ts @@ -487,7 +487,6 @@ describe("pushModels — mapped-before-unmapped ordering (PROD-2250)", () => { describe("pushModels — cross-kind reference-name collision (PROD-2315)", () => { it("fails a same-name model of a different kind with an actionable message instead of creating it", async () => { - stateModule.clearBlockedModelRegistry(); const saveModel = jest.fn(); jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel)); @@ -508,11 +507,6 @@ describe("pushModels — cross-kind reference-name collision (PROD-2315)", () => expect(result.failureDetails![0].name).toBe("LinkCard"); expect(result.failureDetails![0].error).toMatch(/component\/module model on the source/); expect(result.failureDetails![0].error).toMatch(/content model with that reference name already exists/); - - // PROD-2315 (Tier 2): the block is registered so dependents can attribute their own failures. - const blocked = stateModule.getBlockedModel("LinkCard"); - expect(blocked).toBeDefined(); - expect(blocked!.sourceModelID).toBe(sourceModel.id); }); it("does NOT flag a same-name model when the kinds are unknown (falls back to adopt-by-reference)", async () => { diff --git a/src/types/sourceData.ts b/src/types/sourceData.ts index e760c0b..e927883 100644 --- a/src/types/sourceData.ts +++ b/src/types/sourceData.ts @@ -41,9 +41,6 @@ export interface FailureDetail { contentID?: number; // Source content ID for content links guid?: string; // Source instance GUID locale?: string; // Locale code - // PROD-2315: reference name of a model whose cross-kind collision blocked this entity. Dependents - // tagged with the same value are collapsed into one line in the ERROR SUMMARY. - blockedBy?: string; } /**