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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agility/cli",
"version": "1.2.0",
"version": "1.2.1",
"repository": {
"type": "git",
"url": "https://github.com/agility/agility-cli.git"
Expand Down
6 changes: 3 additions & 3 deletions src/core/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
state,
clearFailedContentRegistry,
clearHeldBackTemplateRegistry,
clearEffectiveTargetModels,
clearSavedTargets,
clearOtherLocaleStagingRegistry,
getOtherLocaleStaging,
getPageCmsLink,
Expand Down Expand Up @@ -103,8 +103,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();
// Entities saved by a previous run must not stand in for this run's pulled targets
clearSavedTargets();
clearOtherLocaleStagingRegistry();

// Preflight (PROD-2203): start each run with a clean report of planned actions
Expand Down
82 changes: 61 additions & 21 deletions src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,12 @@ export interface State {
// 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>;
// Saved targets - the target entity each pusher saved this run (or, for models in preflight, the
// definition it would save), by kind, then by SOURCE ID. The mappers' getMappedEntity(…, "target")
// read this before the target cache on disk, which holds the target as pulled at the start of the
// run and is stale for anything saved since. Generalizes PROD-2542's model registry and replaces
// PROD-2346's container cache write; preflight cannot write files, but can record a plan here.
savedTargets: Map<SavedTargetKind, Map<number, SavedTarget>>;

// 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 @@ -208,8 +209,8 @@ 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(),
// Saved targets - target entities saved (or, for models in preflight, planned) during this run
savedTargets: new Map(),

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

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

/**
Expand Down Expand Up @@ -818,27 +819,66 @@ export function clearFailedContentRegistry(): void {
}

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

/**
* Record the model definition content of `sourceReferenceName` will be validated against on the
* target for the rest of this run.
* Entity kinds whose save returns the complete target entity, so the saved copy can stand in for the
* pulled one. Content items and pages are deliberately not here: their pushers only hold a partial
* target (the payload plus the new ID and version), and their pulled target copy is what conflict
* detection compares against (hasTargetChanged, PROD-2628).
*/
export function registerEffectiveTargetModel(sourceReferenceName: string, model: any): void {
if (!sourceReferenceName || !model) return;
state.effectiveTargetModels.set(sourceReferenceName.toLowerCase(), model);
export type SavedTargetKind = "model" | "container" | "template" | "gallery" | "asset";

export interface SavedTarget {
entity: any;
sourceReferenceName?: string;
}

/**
* Record the target entity saved this run for the source entity `sourceID`. A later save of the same
* source entity replaces it.
*/
export function recordSavedTarget(
kind: SavedTargetKind,
sourceID: number,
entity: any,
sourceReferenceName?: string
): void {
if (sourceID == null || !entity) return;
let saved = state.savedTargets.get(kind);
if (!saved) {
saved = new Map();
state.savedTargets.set(kind, saved);
}
saved.set(sourceID, { entity, sourceReferenceName });
}

/** The target entity saved this run for the source entity `sourceID`, if any. */
export function getSavedTarget(kind: SavedTargetKind, sourceID: number): any | undefined {
if (sourceID == null) return undefined;
return state.savedTargets.get(kind)?.get(sourceID)?.entity;
}

/** Look up a model registered this run by its source referenceName (case-insensitive). */
export function getEffectiveTargetModel(sourceReferenceName: string): any | undefined {
/**
* The target entity saved this run for the source entity with this referenceName (case-insensitive),
* if any. For callers that know a source entity only by name, e.g. content validation, which has the
* item's definitionName and, in preflight, a model that has no mapping yet.
*/
export function getSavedTargetBySourceReferenceName(
kind: SavedTargetKind,
sourceReferenceName: string
): any | undefined {
if (!sourceReferenceName) return undefined;
return state.effectiveTargetModels.get(sourceReferenceName.toLowerCase());
const name = sourceReferenceName.toLowerCase();
const saved = state.savedTargets.get(kind);
if (!saved) return undefined;
return Array.from(saved.values()).find((s) => s.sourceReferenceName?.toLowerCase() === name)?.entity;
}

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

/**
Expand Down
44 changes: 31 additions & 13 deletions src/core/tests/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import {
registerFailedContent,
getFailedContent,
clearFailedContentRegistry,
registerEffectiveTargetModel,
getEffectiveTargetModel,
clearEffectiveTargetModels,
recordSavedTarget,
getSavedTarget,
getSavedTargetBySourceReferenceName,
clearSavedTargets,
initializeLogger,
initializeGuidLogger,
getLoggerForGuid,
Expand Down Expand Up @@ -337,20 +338,37 @@ describe("failed content registry", () => {
});
});

describe("effective target model registry (PROD-2542)", () => {
it("registers and looks up by source referenceName, case-insensitively", () => {
describe("saved target entities", () => {
it("looks up by kind and source ID", () => {
const template = { pageTemplateID: 70, contentSectionDefinitions: [] };
recordSavedTarget("template", 7, template, "Home");
expect(getSavedTarget("template", 7)).toBe(template);
expect(getSavedTarget("template", 8)).toBeUndefined();
expect(getSavedTarget("container", 7)).toBeUndefined();
});

it("replaces an earlier save of the same source entity", () => {
const first = { id: 10, fields: [] };
const second = { id: 10, fields: [{ name: "Title" }] };
recordSavedTarget("model", 1, first, "Post");
recordSavedTarget("model", 1, second, "Post");
expect(getSavedTarget("model", 1)).toBe(second);
});

it("looks up by source referenceName, case-insensitively (PROD-2542)", () => {
const model = { referenceName: "PromoBanner", fields: [] };
registerEffectiveTargetModel("PromoBanner", model);
expect(getEffectiveTargetModel("promobanner")).toBe(model);
recordSavedTarget("model", 3, model, "PromoBanner");
expect(getSavedTargetBySourceReferenceName("model", "promobanner")).toBe(model);
expect(getSavedTargetBySourceReferenceName("container", "promobanner")).toBeUndefined();
});

it("is cleared by clearEffectiveTargetModels and resetState", () => {
registerEffectiveTargetModel("A", { fields: [] });
clearEffectiveTargetModels();
expect(getEffectiveTargetModel("A")).toBeUndefined();
registerEffectiveTargetModel("B", { fields: [] });
it("is cleared by clearSavedTargets and resetState", () => {
recordSavedTarget("model", 1, { fields: [] }, "A");
clearSavedTargets();
expect(getSavedTarget("model", 1)).toBeUndefined();
recordSavedTarget("model", 2, { fields: [] }, "B");
resetState();
expect(getEffectiveTargetModel("B")).toBeUndefined();
expect(getSavedTargetBySourceReferenceName("model", "B")).toBeUndefined();
});
});

Expand Down
7 changes: 7 additions & 0 deletions src/lib/mappers/asset-mapper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fileOperations } from "../../core";
import { getSavedTarget } from "../../core/state";
import * as mgmtApi from "@agility/management-sdk";

interface AssetMapping {
Expand Down Expand Up @@ -153,6 +154,12 @@ export class AssetMapper {
}

getMappedEntity(mapping: AssetMapping, type: "source" | "target"): mgmtApi.Media | null {
if (!mapping) return null;
// Something saved to the target earlier in this run is newer than the pulled copy on disk.
if (type === "target") {
const saved = getSavedTarget("asset", mapping.sourceMediaID);
if (saved) return saved;
}
const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid;
const mediaID = type === "source" ? mapping.sourceMediaID : mapping.targetMediaID;
const fileOps = new fileOperations(guid);
Expand Down
8 changes: 7 additions & 1 deletion src/lib/mappers/container-mapper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { parse } from "date-fns";
import { fileOperations } from "../../core";
import { getSavedTarget } from "../../core/state";
import * as mgmtApi from "@agility/management-sdk";

//TODO: Change to use lastModifiedOn instead of lastModifiedDate when the fix to that is deployed!
Expand Down Expand Up @@ -62,6 +63,11 @@ export class ContainerMapper {

getMappedEntity(mapping: ContainerMapping, type: "source" | "target"): mgmtApi.Container | null {
if (!mapping) return null;
// Something saved to the target earlier in this run is newer than the pulled copy on disk.
if (type === "target") {
const saved = getSavedTarget("container", mapping.sourceContentViewID);
if (saved) return saved;
}
//fetch the container from the file system based on source or target GUID
const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid;
const containerID = type === "source" ? mapping.sourceContentViewID : mapping.targetContentViewID;
Expand All @@ -78,7 +84,7 @@ export class ContainerMapper {
return this.getMappedEntity(mapping, type);
} else {
//if there's no mappping, we have to loop through ALL the containers to find it
const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid;
const guid = type === "source" ? this.sourceGuid : this.targetGuid;
const fileOps = new fileOperations(guid);
const containerFiles = fileOps.listFilesInFolder(`containers`);

Expand Down
2 changes: 2 additions & 0 deletions src/lib/mappers/content-item-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export class ContentItemMapper {
}

getMappedEntity(mapping: ContentItemMapping, type: "source" | "target"): mgmtApi.ContentItem | null {
// Deliberately reads only the pulled copy, never a saved target (see SavedTargetKind): the
// pusher holds a partial target, and this pulled copy is what conflict detection compares to.
//fetch the content item from the file system based on source or target GUID
if (!mapping) {
return null;
Expand Down
13 changes: 10 additions & 3 deletions src/lib/mappers/gallery-mapper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { parse } from "date-fns";
import * as mgmtApi from "@agility/management-sdk";
import { fileOperations } from "../../core";
import { getSavedTarget } from "../../core/state";
interface GalleryMapping {
sourceGuid: string;
targetGuid: string;
Expand Down Expand Up @@ -46,15 +47,21 @@ export class GalleryMapper {

getMappedEntity(mapping: GalleryMapping | null, type: "source" | "target"): mgmtApi.assetMediaGrouping | null {
if (!mapping) return null;
// Something saved to the target earlier in this run is newer than the pulled copy on disk.
if (type === "target") {
const saved = getSavedTarget("gallery", mapping.sourceMediaGroupingID);
if (saved) return saved;
}
const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid;
const mediaGroupingID = type === "source" ? mapping.sourceMediaGroupingID : mapping.targetMediaGroupingID;
const fileOps = new fileOperations(guid);
const galleriesFiles = fileOps.getFolderContents("galleries");
// getFolderContents takes a full path; a bare "galleries" resolved against the working
// directory, so the pulled galleries were never found (and an empty folder was created there).
const galleriesFiles = fileOps.getFolderContents(fileOps.getDataFolderPath("galleries"));

console.log("galleriesFiles", galleriesFiles);
for (const galleryFile of galleriesFiles) {
const galleryData = fileOps.readJsonFile(`galleries/${galleryFile}`);
if (galleryData.mediaGroupingID === mediaGroupingID) {
if (galleryData?.mediaGroupingID === mediaGroupingID) {
return galleryData;
}
}
Expand Down
6 changes: 6 additions & 0 deletions src/lib/mappers/model-mapper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fileOperations } from "../../core";
import { getSavedTarget } from "../../core/state";
import * as mgmtApi from "@agility/management-sdk";

interface ModelMapping {
Expand Down Expand Up @@ -99,6 +100,11 @@ export class ModelMapper {

getMappedEntity(mapping: ModelMapping, type: "source" | "target"): mgmtApi.Model | null {
if (!mapping) return null;
// Something saved to the target earlier in this run is newer than the pulled copy on disk.
if (type === "target") {
const saved = getSavedTarget("model", mapping.sourceID);
if (saved) return saved;
}
//fetch the model from the file system based on source or target GUID
const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid;
const modelID = type === "source" ? mapping.sourceID : mapping.targetID;
Expand Down
2 changes: 2 additions & 0 deletions src/lib/mappers/page-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export class PageMapper {
}

getMappedEntity(mapping: PageMapping, type: "source" | "target"): mgmtApi.PageItem | null {
// Deliberately reads only the pulled copy, never a saved target (see SavedTargetKind): the
// pusher holds a partial target, and this pulled copy is what conflict detection compares to.
if (!mapping) return null;
const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid;
const pageID = type === "source" ? mapping.sourcePageID : mapping.targetPageID;
Expand Down
6 changes: 6 additions & 0 deletions src/lib/mappers/template-mapper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fileOperations } from "../../core";
import { getSavedTarget } from "../../core/state";
import * as mgmtApi from "@agility/management-sdk";
import { SectionMapper } from "./section-mapper";
interface TemplateMapping {
Expand Down Expand Up @@ -55,6 +56,11 @@ export class TemplateMapper {

getMappedEntity(mapping: TemplateMapping, type: "source" | "target"): mgmtApi.PageModel | null {
if (!mapping) return null;
// Something saved to the target earlier in this run is newer than the pulled copy on disk.
if (type === "target") {
const saved = getSavedTarget("template", mapping.sourcePageTemplateID);
if (saved) return saved;
}
const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid;
const pageTemplateID = type === "source" ? mapping.sourcePageTemplateID : mapping.targetPageTemplateID;
const fileOps = new fileOperations(guid);
Expand Down
12 changes: 12 additions & 0 deletions src/lib/mappers/tests/container-mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,15 @@ describe("ContainerMapper.hasTargetChanged", () => {
);
});
});

describe("ContainerMapper — getContainerByReferenceName without a mapping", () => {
it("scans the instance's containers instead of throwing", () => {
const mapper = new ContainerMapper("src-cbr", "tgt-cbr");
const containerDir = path.join(tmpDir, "tgt-cbr", "containers");
fs.mkdirSync(containerDir, { recursive: true });
fs.writeFileSync(path.join(containerDir, "77.json"), JSON.stringify({ contentViewID: 77, referenceName: "Posts" }));

expect(mapper.getContainerByReferenceName("posts", "target")).toMatchObject({ contentViewID: 77 });
expect(mapper.getContainerByReferenceName("missing", "target")).toBeNull();
});
});
21 changes: 21 additions & 0 deletions src/lib/mappers/tests/gallery-mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,24 @@ describe("GalleryMapper.hasTargetChanged", () => {
expect(mapper.hasTargetChanged(makeGallery({ mediaGroupingID: 20, modifiedOn: "12/31/2025 11:59PM" }))).toBe(true);
});
});

describe("GalleryMapper — getMappedEntity", () => {
it("finds the pulled gallery in the instance's galleries folder, not the working directory", () => {
const mapper = new GalleryMapper("src-gme", "tgt-gme");
const galleryDir = path.join(tmpDir, "tgt-gme", "galleries");
fs.mkdirSync(galleryDir, { recursive: true });
fs.writeFileSync(path.join(galleryDir, "40.json"), JSON.stringify({ mediaGroupingID: 40, name: "Hero" }));

const mapping = {
sourceGuid: "src-gme",
targetGuid: "tgt-gme",
sourceMediaGroupingID: 4,
targetMediaGroupingID: 40,
sourceModifiedOn: "",
targetModifiedOn: "",
};

expect(mapper.getMappedEntity(mapping, "target")).toMatchObject({ mediaGroupingID: 40, name: "Hero" });
expect(fs.existsSync(path.join(process.cwd(), "galleries"))).toBe(false);
});
});
Loading
Loading