From 1a4ebbd45fc734883bd3f35b92db7057fd2f4e56 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 23 Jul 2026 21:42:14 -0400 Subject: [PATCH 1/2] PROD-2346: write local container cache on create/update/adopt container-pusher.ts wrote the mapping row after a successful create, update, or PROD-2307 referenceName-adopt, but never the local containers/{id}.json cache file that ContainerMapper.getMappedEntity reads from disk. That left a same-run gap: a container created and then pushed content into in the same sync would have its content rejected with the PROD-2309 "container no longer exists" guard, since the guard can't tell "just created, not cached yet" apart from "genuinely deleted since last sync". --- src/lib/pushers/container-pusher.ts | 14 +++ .../pushers/tests/container-pusher.test.ts | 106 ++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/src/lib/pushers/container-pusher.ts b/src/lib/pushers/container-pusher.ts index 0576a95..218d901 100644 --- a/src/lib/pushers/container-pusher.ts +++ b/src/lib/pushers/container-pusher.ts @@ -1,12 +1,23 @@ import * as mgmtApi from "@agility/management-sdk"; import { ApiClient } from "@agility/management-sdk"; import { getLoggerForGuid, state } from "core/state"; +import { fileOperations } from "core"; import { ContainerMapper } from "lib/mappers/container-mapper"; import { ModelMapper } from "lib/mappers/model-mapper"; import { Logs } from "core/logs"; import { FailureDetail, PusherResult } from "types/sourceData"; import { preflightReport } from "../preflight/preflight-report"; +/** + * Persist the target container to the local disk cache (agility-files/{targetGuid}/containers/{id}.json), + * mirroring what a download-containers.ts pull would produce. Without this, ContainerMapper.getMappedEntity + * (which only reads from disk) can't distinguish a container that was just created/updated in this same run + * from one that was genuinely deleted since the last sync (PROD-2346). + */ +function cacheTargetContainer(targetGuid: string, container: mgmtApi.Container) { + new fileOperations(targetGuid).exportFiles("containers", container.contentViewID.toString(), container); +} + /** * Container pusher with enhanced version-based comparison * Uses lastModifiedDate for intelligent update decisions @@ -104,6 +115,7 @@ export async function pushContainers( if (!state.preflight) { containerMapper.addMapping(sourceContainer, targetByRef); + cacheTargetContainer(targetGuid[0], targetByRef); } logger.container.skipped(sourceContainer, "already exists on target; mapping row created", targetGuid[0]); preflightReport.record({ @@ -208,6 +220,7 @@ export async function pushContainers( } containerMapper.updateMapping(sourceContainer, updateResult, sourceMapping); + cacheTargetContainer(targetGuid[0], updateResult); successful++; } else { logger.container.error(sourceContainer, "Failed to update container", targetGuid[0]); @@ -251,6 +264,7 @@ export async function pushContainers( if (createResult) { logger.container.created(sourceContainer, "created", targetGuid[0]); containerMapper.addMapping(sourceContainer, createResult); + cacheTargetContainer(targetGuid[0], createResult); successful++; } else { logger.container.error(sourceContainer, "Failed to create container", targetGuid[0]); diff --git a/src/lib/pushers/tests/container-pusher.test.ts b/src/lib/pushers/tests/container-pusher.test.ts index ab22c5f..b57d4c1 100644 --- a/src/lib/pushers/tests/container-pusher.test.ts +++ b/src/lib/pushers/tests/container-pusher.test.ts @@ -233,6 +233,112 @@ describe("pushContainers — adopt existing target container by referenceName (P }); }); +// ─── pushContainers — PROD-2346 local cache write ───────────────────────────── + +describe("pushContainers — writes local container cache file (PROD-2346)", () => { + function cachedContainerPath(guid: string, contentViewID: number): string { + return path.join(tmpDir, guid, "containers", `${contentViewID}.json`); + } + + it("writes the target container to disk after a successful create", async () => { + const created = makeContainer({ + contentViewID: 501, + contentDefinitionID: 1, + lastModifiedDate: "01/01/2024 10:00AM", + }); + const saveContainer = jest.fn().mockResolvedValue(created); + state.cachedApiClient = { containerMethods: { saveContainer } } as any; + + const { pushContainers } = await import("../container-pusher"); + + const sourceContainer = makeContainer({ contentDefinitionID: 1 }); + + const result = await pushContainers([sourceContainer], []); + expect(result.successful).toBe(1); + + const cachePath = cachedContainerPath("tgt-cont-u", 501); + expect(fs.existsSync(cachePath)).toBe(true); + expect(JSON.parse(fs.readFileSync(cachePath, "utf8"))).toMatchObject({ + contentViewID: 501, + referenceName: created.referenceName, + lastModifiedDate: "01/01/2024 10:00AM", + }); + }); + + it("writes the target container to disk after a successful update", async () => { + const referenceName = "UpdateCacheTest"; + + // First run: create the container, establishing the mapping and initial cache file. + const created = makeContainer({ + contentViewID: 502, + contentDefinitionID: 1, + referenceName, + lastModifiedDate: "01/01/2024 10:00AM", + }); + const saveContainer = jest.fn().mockResolvedValue(created); + state.cachedApiClient = { containerMethods: { saveContainer } } as any; + + const { pushContainers } = await import("../container-pusher"); + + const sourceV1 = makeContainer({ + contentViewID: 900, + contentDefinitionID: 1, + referenceName, + lastModifiedDate: "01/01/2024 10:00AM", + }); + await pushContainers([sourceV1], []); + + // Second run: source changed, target unchanged → update path. + const updated = makeContainer({ + contentViewID: 502, + contentDefinitionID: 1, + referenceName, + lastModifiedDate: "06/01/2025 10:00AM", + }); + saveContainer.mockResolvedValue(updated); + + const sourceV2 = { ...sourceV1, lastModifiedDate: "06/01/2025 10:00AM" }; + const targetUnchanged = { ...created }; + + const result = await pushContainers([sourceV2], [targetUnchanged]); + expect(result.successful).toBe(1); + + const cachePath = cachedContainerPath("tgt-cont-u", 502); + expect(fs.existsSync(cachePath)).toBe(true); + expect(JSON.parse(fs.readFileSync(cachePath, "utf8"))).toMatchObject({ + contentViewID: 502, + referenceName, + lastModifiedDate: "06/01/2025 10:00AM", + }); + }); + + it("writes the target container to disk after adopting by referenceName (PROD-2307)", async () => { + const saveContainer = jest.fn(); + state.cachedApiClient = { containerMethods: { saveContainer } } as any; + + const { pushContainers } = await import("../container-pusher"); + + const source = makeContainer({ referenceName: "AdoptCacheTest", contentDefinitionID: 500 }); + const target = makeContainer({ + contentViewID: 503, + referenceName: "AdoptCacheTest", + contentDefinitionID: 500, + lastModifiedDate: "01/01/2024 10:00AM", + }); + + const result = await pushContainers([source], [target]); + expect(result.skipped).toBe(1); + expect(saveContainer).not.toHaveBeenCalled(); + + const cachePath = cachedContainerPath("tgt-cont-u", 503); + expect(fs.existsSync(cachePath)).toBe(true); + expect(JSON.parse(fs.readFileSync(cachePath, "utf8"))).toMatchObject({ + contentViewID: 503, + referenceName: "AdoptCacheTest", + }); + }); +}); + // ─── pushContainers — result shape ──────────────────────────────────────────── describe("pushContainers — result shape", () => { From b70b70a180b643cfbd2d4d5dcf8ecd6de10f72a3 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 24 Jul 2026 13:13:09 -0400 Subject: [PATCH 2/2] version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9f3d9e4..de10096 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agility/cli", - "version": "1.0.0-beta.13.24", + "version": "1.0.0-beta.13.26", "description": "Agility CLI for working with your content. (Public Beta)", "repository": { "type": "git",