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
13 changes: 12 additions & 1 deletion src/lib/pushers/model-pusher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,18 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp
// The ID-based rename guard below misses this because the two records have DIFFERENT source IDs
// (a duplicate reference name, not a duplicate ID). Throw a "Model validation failed" error so the
// orchestrator halts the sync before any partial push, rather than dropping content unnoticed.
const duplicateMappings = referenceMapper.getDuplicateSourceReferenceNames();
//
// Scope the gate to duplicates that still matter: only halt when at least one of the conflicting
// source IDs is still LIVE in this pull (i.e. present in `models`) — that is the PROD-1492 case
// (dead record + live record, e.g. PromoBanner 46-dead / 48-live) where content would be dropped.
// If EVERY record for a reference name points at a source model that no longer exists, the model
// was deleted outright: there is no content of that type in the push and nothing references it, so
// halting would be a false positive that blocks the whole sync over stale mapping residue. Ignore
// those silently and continue.
const liveSourceIDs = new Set<number>(models.map((m) => m.id));
const duplicateMappings = referenceMapper
.getDuplicateSourceReferenceNames()
.filter((d) => d.sourceIDs.some((id) => liveSourceIDs.has(id)));
if (duplicateMappings.length > 0) {
const detail = duplicateMappings
.map((d) => `"${d.referenceName}" (source IDs: ${d.sourceIDs.join(", ")})`)
Expand Down
43 changes: 39 additions & 4 deletions src/lib/pushers/tests/model-pusher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,11 @@ describe("pushModels — source-side rename orphans a mapping and halts the sync
// ─── pushModels — duplicate reference-name mapping halts the sync (PROD-1492) ──────

describe("pushModels — deleted-and-recreated model leaves a duplicate mapping and halts the sync (PROD-1492)", () => {
it('throws "Model validation failed" (and writes nothing) when two mapping records share a reference name with different source IDs', async () => {
it('throws "Model validation failed" (and writes nothing) when a live model shares a reference name with a dead duplicate record', async () => {
const { ModelMapper } = await import("lib/mappers/model-mapper");

// Seed the mapping exactly as it looks after a deleted-and-recreated source model:
// dead source 46 -> target 138 and live source 48 -> target 139, both named "PromoBanner".
// Seed the mapping exactly as it looks after a deleted-and-recreated source model (the PROD-1492
// PromoBanner case): dead source 46 -> target 138 and live source 48 -> target 139, same name.
const seeder = new ModelMapper(state.sourceGuid[0], state.targetGuid[0]);
seeder.addMapping(
{ id: 46, referenceName: "PromoBanner", lastModifiedDate: new Date(2025, 0, 1).toISOString() } as any,
Expand All @@ -232,7 +232,7 @@ describe("pushModels — deleted-and-recreated model leaves a duplicate mapping

const { pushModels } = await import("../model-pusher");

// Only the live model (48) still exists on the source; the dead record (46) is the stale duplicate.
// The live model (48) still exists on the source; the dead record (46) is the stale duplicate.
const liveModel = makeModel({
id: 48,
referenceName: "PromoBanner",
Expand All @@ -250,6 +250,41 @@ describe("pushModels — deleted-and-recreated model leaves a duplicate mapping

expect(saveModel).not.toHaveBeenCalled();
});

it("does NOT halt when both duplicate records are for a model deleted outright (neither source ID is live)", async () => {
const { ModelMapper } = await import("lib/mappers/model-mapper");

// Real 63b1dc5d-us2 -> 95bfb840-us2 shape: two case-only "changelog" records, BOTH stale —
// ChangeLog source 110 -> target 18 and Changelog source 117 -> target 24.
// Neither source model exists in the current pull anymore (the model was fully deleted), so this
// is stale mapping residue, not a delete-and-recreate. The gate must NOT halt the sync over it.
const seeder = new ModelMapper(state.sourceGuid[0], state.targetGuid[0]);
seeder.addMapping(
{ id: 110, referenceName: "ChangeLog", lastModifiedDate: new Date(2025, 0, 1).toISOString() } as any,
{ id: 18, referenceName: "ChangeLog", lastModifiedDate: new Date(2025, 0, 1).toISOString() } as any
);
seeder.addMapping(
{ id: 117, referenceName: "Changelog", lastModifiedDate: new Date(2025, 1, 1).toISOString() } as any,
{ id: 24, referenceName: "Changelog", lastModifiedDate: new Date(2025, 1, 1).toISOString() } as any
);

const saveModel = jest.fn().mockResolvedValue(makeModel({ id: 999 }));
jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel));

const { pushModels } = await import("../model-pusher");

// The push carries only a live, unrelated model — neither 110 nor 117 is present.
const liveModel = makeModel({
id: 55,
referenceName: "FooterLinks",
lastModifiedDate: new Date(2025, 5, 1).toISOString(),
});

// Must resolve (not throw): the dead-duplicate is logged and ignored, sync continues.
await expect(pushModels([liveModel], [])).resolves.toEqual(
expect.objectContaining({ status: "success" })
);
});
});

// ─── PROD-2211: honest failure reporting ──────────────────────────────────────
Expand Down
Loading