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
10 changes: 8 additions & 2 deletions src/core/json-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export interface JsonSummaryDetail {
mediaID?: number;
guid?: string;
locale?: string;
/** PROD-2542: model of a content item that failed client-side validation. */
model?: string;
/** PROD-2542: the required fields it was missing, as "name (Type)". */
fields?: string[];
}

/**
Expand Down Expand Up @@ -71,16 +75,18 @@ export interface JsonSummaryPreflightPhase {
update: number;
skip: number;
conflict: number;
/** PROD-2542: would-be writes that fail validation against the target model. */
invalid: number;
}

export interface JsonSummaryPreflight {
totals: { create: number; update: number; skip: number; conflict: number };
totals: { create: number; update: number; skip: number; conflict: number; invalid: number };
hasConflicts: boolean;
phases: JsonSummaryPreflightPhase[];
/** Every recorded action, in the order the pushers recorded them. */
entries: Array<{
phase: string;
action: "create" | "update" | "skip" | "conflict";
action: "create" | "update" | "skip" | "conflict" | "invalid";
name: string;
locale?: string;
detail?: string;
Expand Down
3 changes: 3 additions & 0 deletions src/core/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
state,
clearFailedContentRegistry,
clearHeldBackTemplateRegistry,
clearEffectiveTargetModels,
getPageCmsLink,
getContentCmsLink,
} from "./state";
Expand Down Expand Up @@ -56,6 +57,8 @@ export class Push {
// Clear failed content and held-back template registries from any previous sync
clearFailedContentRegistry();
clearHeldBackTemplateRegistry();
// PROD-2542: models saved by a previous run must not stand in for this run's target models
clearEffectiveTargetModels();

// Preflight (PROD-2203): start each run with a clean report of planned actions
preflightReport.reset();
Expand Down
57 changes: 50 additions & 7 deletions src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,15 @@ export interface State {
mappingPair?: { sourceGuid: string; targetGuid: string };

// 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<number, { referenceName: string; error: string; locale: string }>;
// Used by page pusher to provide better error messages when content mappings are missing.
// Keyed by `${locale}:${contentID}` (PROD-2542) so a failure in one locale never labels another.
failedContentRegistry: Map<string, { referenceName: string; error: string; locale: string }>;

// Effective target models (PROD-2542) - the model definition content will actually be validated
// against on the target, keyed by lower-cased SOURCE model referenceName. Filled by the model
// pusher on a saved create/update (and with the source model in preflight), because the target
// models/<id>.json cache is not rewritten after saveModel and is stale for the rest of the run.
effectiveTargetModels: Map<string, any>;

// Held-back template registry - source template names whose mapped target template is gone and
// was not recreated (no --overwrite). Used by page pusher to skip pages that depend on them.
Expand Down Expand Up @@ -187,6 +194,9 @@ export const state: State = {
// Failed content registry - tracks content items that failed during sync
failedContentRegistry: new Map(),

// Effective target models - models saved (or, in preflight, planned) during this run
effectiveTargetModels: new Map(),

// Held-back template registry - templates whose mapped target is gone and was not recreated
heldBackTemplateRegistry: new Map(),
};
Expand Down Expand Up @@ -490,6 +500,9 @@ export function resetState() {
// Reverse sync
state.reverseSync = false;
state.mappingPair = undefined;

// Run-scoped model registry (PROD-2542)
state.effectiveTargetModels = new Map();
}

/**
Expand Down Expand Up @@ -751,26 +764,32 @@ export function clearLogger(): void {
// better error messages when content mappings are missing
// ============================================================================

function failedContentKey(contentID: number, locale: string): string {
return `${(locale || "").toLowerCase()}:${contentID}`;
}

/**
* Register a failed content item
* @param contentID - The source content ID that failed
* @param referenceName - The reference name of the content item
* @param error - The error message
* @param locale - The locale being processed
* @param locale - The locale being processed (part of the key, PROD-2542)
*/
export function registerFailedContent(contentID: number, referenceName: string, error: string, locale: string): void {
state.failedContentRegistry.set(contentID, { referenceName, error, locale });
state.failedContentRegistry.set(failedContentKey(contentID, locale), { referenceName, error, locale });
}

/**
* Look up a failed content item by its source content ID
* Look up a failed content item by its source content ID in a locale
* @param contentID - The source content ID to look up
* @param locale - The locale being processed; a failure registered for another locale is not returned
* @returns The failure info if found, or undefined
*/
export function getFailedContent(
contentID: number
contentID: number,
locale: string
): { referenceName: string; error: string; locale: string } | undefined {
return state.failedContentRegistry.get(contentID);
return state.failedContentRegistry.get(failedContentKey(contentID, locale));
}

/**
Expand All @@ -780,6 +799,30 @@ export function clearFailedContentRegistry(): void {
state.failedContentRegistry.clear();
}

// ============================================================================
// Effective target model registry (PROD-2542)
// ============================================================================

/**
* Record the model definition content of `sourceReferenceName` will be validated against on the
* target for the rest of this run.
*/
export function registerEffectiveTargetModel(sourceReferenceName: string, model: any): void {
if (!sourceReferenceName || !model) return;
state.effectiveTargetModels.set(sourceReferenceName.toLowerCase(), model);
}

/** Look up a model registered this run by its source referenceName (case-insensitive). */
export function getEffectiveTargetModel(sourceReferenceName: string): any | undefined {
if (!sourceReferenceName) return undefined;
return state.effectiveTargetModels.get(sourceReferenceName.toLowerCase());
}

/** Clear the effective target model registry (called at start of each sync). */
export function clearEffectiveTargetModels(): void {
state.effectiveTargetModels.clear();
}

/**
* Register a source template that was held back because its mapped target template no longer
* exists and --overwrite was not set
Expand Down
39 changes: 37 additions & 2 deletions src/core/tests/json-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,9 @@ describe("buildJsonSummary — shape", () => {
const summary = buildJsonSummary(
makeInput({
preflight: {
totals: { create: 2, update: 1, skip: 0, conflict: 1 },
totals: { create: 2, update: 1, skip: 0, conflict: 1, invalid: 0 },
hasConflicts: true,
phases: [{ phase: "Models", create: 2, update: 1, skip: 0, conflict: 1 }],
phases: [{ phase: "Models", create: 2, update: 1, skip: 0, conflict: 1, invalid: 0 }],
entries: [{ phase: "Models", action: "conflict", name: "LinkCard" }],
},
})
Expand Down Expand Up @@ -372,3 +372,38 @@ describe("buildAbortSummary", () => {
});
});
});

// ─── PROD-2542: client-side validation failures ──────────────────────────────

describe("buildJsonSummary — required-field validation failures (PROD-2542)", () => {
it("carries the model and missing fields of a content failure through to the JSON", () => {
const failure = {
name: "home_promobanner",
error:
"Missing required field(s) on model PromoBanner: promoTitle (Text), promoSubtitle (Text) — skipped client-side",
type: "content" as const,
contentID: 12942,
guid: "source-u",
locale: "en-us",
model: "PromoBanner",
fields: ["promoTitle (Text)", "promoSubtitle (Text)"],
};
const summary = buildJsonSummary(makeInput({ failures: [failure] }));
expect(JSON.parse(JSON.stringify(summary)).failures).toEqual([failure]);
});

it("carries the preflight invalid count additively", () => {
const preflight = {
totals: { create: 1, update: 0, skip: 0, conflict: 0, invalid: 1 },
hasConflicts: false,
phases: [{ phase: "Content", create: 1, update: 0, skip: 0, conflict: 0, invalid: 1 }],
entries: [
{ phase: "Content", action: "invalid" as const, name: "home_promobanner", locale: "en-us", detail: "x" },
{ phase: "Content", action: "create" as const, name: "ok", locale: "en-us" },
],
};
const summary = buildJsonSummary(makeInput({ preflight }));
expect(summary.preflight?.totals.invalid).toBe(1);
expect(summary.preflight?.hasConflicts).toBe(false);
});
});
41 changes: 38 additions & 3 deletions src/core/tests/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import {
registerFailedContent,
getFailedContent,
clearFailedContentRegistry,
registerEffectiveTargetModel,
getEffectiveTargetModel,
clearEffectiveTargetModels,
initializeLogger,
initializeGuidLogger,
getLoggerForGuid,
Expand Down Expand Up @@ -304,18 +307,50 @@ describe("getApiKeysForGuid / getAllApiKeys", () => {
describe("failed content registry", () => {
it("registers and retrieves a failed content item", () => {
registerFailedContent(123, "my-ref", "some error", "en-us");
const result = getFailedContent(123);
const result = getFailedContent(123, "en-us");
expect(result).toEqual({ referenceName: "my-ref", error: "some error", locale: "en-us" });
});

it("returns undefined for unknown content ID", () => {
expect(getFailedContent(999)).toBeUndefined();
expect(getFailedContent(999, "en-us")).toBeUndefined();
});

it("clears the registry", () => {
registerFailedContent(1, "ref", "err", "en-us");
clearFailedContentRegistry();
expect(getFailedContent(1)).toBeUndefined();
expect(getFailedContent(1, "en-us")).toBeUndefined();
});

// PROD-2542: the same source contentID failing in es-us must not label the en-us item.
it("scopes failures by locale", () => {
registerFailedContent(42, "ref-es", "es error", "es-us");
expect(getFailedContent(42, "en-us")).toBeUndefined();
expect(getFailedContent(42, "es-us")).toEqual({ referenceName: "ref-es", error: "es error", locale: "es-us" });
registerFailedContent(42, "ref-en", "en error", "en-us");
expect(getFailedContent(42, "en-us")?.error).toBe("en error");
expect(getFailedContent(42, "es-us")?.error).toBe("es error");
});

it("matches locale case-insensitively", () => {
registerFailedContent(7, "ref", "err", "en-US");
expect(getFailedContent(7, "en-us")).toBeDefined();
});
});

describe("effective target model registry (PROD-2542)", () => {
it("registers and looks up by source referenceName, case-insensitively", () => {
const model = { referenceName: "PromoBanner", fields: [] };
registerEffectiveTargetModel("PromoBanner", model);
expect(getEffectiveTargetModel("promobanner")).toBe(model);
});

it("is cleared by clearEffectiveTargetModels and resetState", () => {
registerEffectiveTargetModel("A", { fields: [] });
clearEffectiveTargetModels();
expect(getEffectiveTargetModel("A")).toBeUndefined();
registerEffectiveTargetModel("B", { fields: [] });
resetState();
expect(getEffectiveTargetModel("B")).toBeUndefined();
});
});

Expand Down
41 changes: 33 additions & 8 deletions src/lib/preflight/preflight-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
import ansiColors from "ansi-colors";
import { state } from "../../core/state";

export type PreflightAction = "create" | "update" | "skip" | "conflict";
/**
* "invalid" (PROD-2542): the item would be created/updated, but fails client-side validation against
* the target model (e.g. a missing required field), so a real run skips it and reports a failure.
* It is a warning in the plan: not a write, and — unlike "conflict" — it doesn't set the exit code.
*/
export type PreflightAction = "create" | "update" | "skip" | "conflict" | "invalid";

export interface PreflightEntry {
/** Phase / element type, e.g. "Models", "Containers", "Content". */
Expand All @@ -34,23 +39,26 @@ export interface PreflightPhaseSummary {
update: number;
skip: number;
conflict: number;
invalid: number;
entries: PreflightEntry[];
}

const ACTION_ORDER: PreflightAction[] = ["create", "update", "skip", "conflict"];
const ACTION_ORDER: PreflightAction[] = ["create", "update", "skip", "conflict", "invalid"];

const ACTION_COLOR: Record<PreflightAction, (s: string) => string> = {
create: ansiColors.green,
update: ansiColors.cyan,
skip: ansiColors.gray,
conflict: ansiColors.red,
invalid: ansiColors.yellow,
};

const ACTION_GLYPH: Record<PreflightAction, string> = {
create: "+",
update: "~",
skip: "·",
conflict: "✗",
invalid: "!",
};

class PreflightReport {
Expand Down Expand Up @@ -90,7 +98,7 @@ class PreflightReport {
for (const entry of this.entries) {
let summary = byPhase.get(entry.phase);
if (!summary) {
summary = { phase: entry.phase, create: 0, update: 0, skip: 0, conflict: 0, entries: [] };
summary = { phase: entry.phase, create: 0, update: 0, skip: 0, conflict: 0, invalid: 0, entries: [] };
byPhase.set(entry.phase, summary);
}
summary[entry.action]++;
Expand All @@ -101,7 +109,7 @@ class PreflightReport {

/** Aggregate totals across all phases. */
getTotals(): Record<PreflightAction, number> {
const totals: Record<PreflightAction, number> = { create: 0, update: 0, skip: 0, conflict: 0 };
const totals: Record<PreflightAction, number> = { create: 0, update: 0, skip: 0, conflict: 0, invalid: 0 };
for (const entry of this.entries) {
totals[entry.action]++;
}
Expand All @@ -122,18 +130,26 @@ class PreflightReport {
toJSON(): {
totals: Record<PreflightAction, number>;
hasConflicts: boolean;
phases: Array<{ phase: string; create: number; update: number; skip: number; conflict: number }>;
phases: Array<{
phase: string;
create: number;
update: number;
skip: number;
conflict: number;
invalid: number;
}>;
entries: PreflightEntry[];
} {
return {
totals: this.getTotals(),
hasConflicts: this.hasConflicts(),
phases: this.getPhaseSummaries().map(({ phase, create, update, skip, conflict }) => ({
phases: this.getPhaseSummaries().map(({ phase, create, update, skip, conflict, invalid }) => ({
phase,
create,
update,
skip,
conflict,
invalid,
})),
entries: this.getEntries(),
};
Expand Down Expand Up @@ -169,7 +185,8 @@ class PreflightReport {
ACTION_COLOR.create(`${phase.create} create, `) +
ACTION_COLOR.update(`${phase.update} update, `) +
ACTION_COLOR.skip(`${phase.skip} skip, `) +
ACTION_COLOR.conflict(`${phase.conflict} conflict`);
ACTION_COLOR.conflict(`${phase.conflict} conflict`) +
(phase.invalid > 0 ? ACTION_COLOR.invalid(`, ${phase.invalid} invalid`) : "");
lines.push(header);

// List entries grouped by action so creates/updates/conflicts are easy to scan.
Expand All @@ -191,8 +208,16 @@ class PreflightReport {
ACTION_COLOR.create(`${totals.create} create, `) +
ACTION_COLOR.update(`${totals.update} update, `) +
ACTION_COLOR.skip(`${totals.skip} skip, `) +
ACTION_COLOR.conflict(`${totals.conflict} conflict`)
ACTION_COLOR.conflict(`${totals.conflict} conflict`) +
(totals.invalid > 0 ? ACTION_COLOR.invalid(`, ${totals.invalid} invalid`) : "")
);
if (totals.invalid > 0) {
lines.push(
ansiColors.yellow(
`\n⚠️ ${totals.invalid} item(s) fail validation against the target model — a real sync would skip them and report them as failures.`
)
);
}
if (this.hasConflicts()) {
lines.push(ansiColors.red("\n⚠️ Conflicts detected — a real sync would require --overwrite (exit code 1)."));
}
Expand Down
Loading
Loading