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
91 changes: 52 additions & 39 deletions src/lib/pushers/model-pusher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp
const { sourceGuid, targetGuid } = state;
const logger = getLoggerForGuid(sourceGuid[0])!;

const modelDefaults: string[] = [
"richtextarea",
"formbuilder",
"agilitycss",
"agilitycodetemplate",
"agilityjavascript",
"agilityformbuilder",
];

if (!models || models.length === 0) {
logger.log("INFO", "No models found to process.");
return { status: "success", successful: 0, failed: 0, skipped: 0 };
Expand All @@ -31,42 +40,27 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp
let shouldSkip = [];
let stubCreated = [];

for (const model of models) {
if (!model.id || !model.referenceName) {
logger.model.error(model, "Model is missing required properties (id or referenceName), skipping", targetGuid[0]);
for (const sourceModel of models) {
if (!sourceModel.id || !sourceModel.referenceName) {
logger.model.error(
sourceModel,
"Model is missing required properties (id or referenceName), skipping",
targetGuid[0],
);
skipped++;
continue;
}

const sourceMapping = referenceMapper.getModelMappingByID(model.id, "source");
const targetModel = targetData.find((targetModel) => targetModel.referenceName === model.referenceName) || null;

// A target model exists by referenceName but has no source mapping, while this model's ID is
// already used as a target ID in another mapping — a sign the source model was renamed/reassigned.
if (!sourceMapping && targetModel) {
const targetMapping = referenceMapper.getModelMappingByID(model.id, "target");
if (targetMapping && targetMapping.targetID === model.id) {
logger.model.error(
model,
new Error(
`A target model named "${model.referenceName}" exists but is not mapped to source ID ${model.id} (likely a rename or reassignment of the source model).`,
),
targetGuid[0],
);
throw new Error(
`Model validation failed: mapping inconsistency for model "${model.referenceName}" (ID: ${model.id}). ` +
`A mapping exists for the target model, but the source model ID does not match — this likely indicates ` +
`a rename or reassignment on the source. Stopping sync to avoid a partial push; review the model mappings and re-run.`,
);
}
}
const sourceMapping = referenceMapper.getModelMappingByID(sourceModel.id, "source");
const targetModel =
targetData.find((targetModel) => targetModel.referenceName === sourceModel.referenceName) || null;

const modelLastModifiedDate = new Date(model.lastModifiedDate);
const modelLastModifiedDate = new Date(sourceModel.lastModifiedDate);
const targetLastModifiedDate = targetModel ? new Date(targetModel.lastModifiedDate) : null;
const mappingLastModifiedDate = sourceMapping ? new Date(sourceMapping.targetLastModifiedDate) : null;
const hasSourceChanged = modelLastModifiedDate > targetLastModifiedDate;
const hasTargetChanged = targetLastModifiedDate > mappingLastModifiedDate;
const sourceFieldCount = model?.fields?.length || 0;
const sourceFieldCount = sourceModel?.fields?.length || 0;
const targetFieldCount = targetModel?.fields?.length || 0;
const fieldCountChanged = sourceFieldCount !== targetFieldCount;

Expand All @@ -76,44 +70,64 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp
// This ensures downstream containers can find their model mappings
const existsInTargetWithoutMapping = !sourceMapping && targetModel;
if (existsInTargetWithoutMapping) {
// Create the mapping for existing target models (ensures containers can reference them)
referenceMapper.addMapping(model, targetModel);
// Add to skip list since model already exists and is up to date
shouldSkip.push(model);
continue; // Skip remaining conditions - mapping is now created, no further action needed
const includesDefault = modelDefaults.includes(sourceModel.referenceName.toLowerCase());

if (includesDefault) {
// Create the mapping for existing target models (ensures containers can reference them)
referenceMapper.addMapping(sourceModel, targetModel);
// Add to skip list since model already exists and is up to date
shouldSkip.push(sourceModel);
continue; // Skip remaining conditions - mapping is now created, no further action needed
} else {
const targetMapping = targetModel.id ? referenceMapper.getModelMappingByID(targetModel.id, "target") : null;
if (targetMapping && targetMapping.sourceID !== sourceModel.id) {
logger.model.error(
sourceModel,
new Error(
`A target model named "${sourceModel.referenceName}" exists but is not mapped to source ID ${sourceModel.id} (likely a rename or reassignment of the source model).`,
),
targetGuid[0],
);
throw new Error(
`Model validation failed: mapping inconsistency for model "${sourceModel.referenceName}" (ID: ${sourceModel.id}). ` +
`A mapping exists for the target model, but the source model ID does not match — this likely indicates ` +
`a rename or reassignment on the source. Stopping sync to avoid a partial push; review the model mappings and re-run.`,
);
}
}
}

if (!sourceMapping && !targetModel) {
shouldCreateStub.push(model);
shouldCreateStub.push(sourceModel);
continue;
}
// if the mapping exists, and the source has changed, we need to update the fields
// Added a special case for RichTextArea to handle the conflict scenario where the source has changed and the target has changed (first sync).
// This will attempt to update the model, and write the mappings
if ((sourceMapping && hasSourceChanged) || (sourceMapping && fieldCountChanged)) {
shouldUpdateFields.push(model);
shouldUpdateFields.push(sourceModel);
continue;
}

if (sourceMapping && (hasTargetChanged || hasSourceChanged) && state.overwrite) {
shouldUpdateFields.push(model);
shouldUpdateFields.push(sourceModel);
continue;
}

// if the mapping exists, and the target has changed, we need to skip the model, not safe to update
if (sourceMapping && hasTargetChanged) {
shouldSkip.push(model);
shouldSkip.push(sourceModel);
continue;
}

// if the mapping exists, and the source and target have not changed, we need to skip the model
if (sourceMapping && !hasSourceChanged && !hasTargetChanged && !state.overwrite) {
shouldSkip.push(model);
shouldSkip.push(sourceModel);
continue;
}

if (sourceMapping && !hasSourceChanged && !hasTargetChanged && state.overwrite) {
shouldSkip.push(model);
shouldSkip.push(sourceModel);
continue;
}
}
Expand All @@ -134,7 +148,6 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp
const modelsToUpdate = [...stubCreated, ...shouldUpdateFields];
for (const model of modelsToUpdate) {
const sourceMapping = referenceMapper.getModelMapping(model, "source");

const result = await updateExistingModel(
model,
sourceMapping.targetID,
Expand Down
61 changes: 57 additions & 4 deletions src/lib/pushers/tests/model-pusher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,21 +95,20 @@ describe("pushModels — result shape", () => {

// ─── pushModels — existsInTargetWithoutMapping ────────────────────────────────

describe("pushModels — model exists in target but no mapping", () => {
describe("pushModels — model exists in target but no mapping and is default", () => {
it("skips model that already exists in target by referenceName but has no mapping", async () => {
const saveModel = jest.fn().mockResolvedValue(makeModel({ id: 999 }));
jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel));

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

const now = new Date().toISOString();
const sourceModel = makeModel({ referenceName: "shared-model", lastModifiedDate: now });
const targetModel = makeModel({ id: 42, referenceName: "shared-model", lastModifiedDate: now });
const sourceModel = makeModel({ referenceName: "agilitycodetemplate", lastModifiedDate: now });
const targetModel = makeModel({ id: 42, referenceName: "agilitycodetemplate", lastModifiedDate: now });

const result = await pushModels([sourceModel], [targetModel]);

// Should skip because it already exists in target
expect(result.skipped).toBe(1);
expect(result.successful).toBe(0);
expect(saveModel).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -149,3 +148,57 @@ describe("pushModels — create stub path", () => {
expect(result.successful).toBe(0);
});
});

// ─── pushModels — source-side rename orphans a mapping and halts the sync (PROD-1439) ──────

describe("pushModels — source-side rename orphans a mapping and halts the sync (PROD-1439)", () => {
it('throws "Model validation failed" (and writes nothing) when a renamed model loses its mapping to a reused-name sibling', async () => {
const { ModelMapper } = await import("lib/mappers/model-mapper");

// Seed the mapping exactly as it looked BEFORE the rename:
// source model 248 ("ContactUsSendMessageForm") -> target model 118.
const seeder = new ModelMapper(state.sourceGuid[0], state.targetGuid[0]);
seeder.addMapping(
{
id: 248,
referenceName: "ContactUsSendMessageForm",
lastModifiedDate: new Date(2025, 0, 1).toISOString(),
} as any,
{
id: 118,
referenceName: "ContactUsSendMessageForm",
lastModifiedDate: new Date(2025, 0, 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");

// On the source: model 248 was renamed to "...Legacy", and a NEW model 254 reused the old name.
const renamedModel = makeModel({
id: 248,
referenceName: "ContactUsSendMessageFormLegacy",
lastModifiedDate: new Date(2025, 11, 4).toISOString(),
});
const reusedNameModel = makeModel({
id: 254,
referenceName: "ContactUsSendMessageForm",
lastModifiedDate: new Date(2025, 11, 4).toISOString(),
});
// Target still only has the original "ContactUsSendMessageForm" (id 118), no "...Legacy".
const targetModel = makeModel({
id: 118,
referenceName: "ContactUsSendMessageForm",
lastModifiedDate: new Date(2025, 0, 1).toISOString(),
});

// 248 is classified for update; processing the reused-name sibling reassigns (steals) the
// shared target-118 mapping, leaving 248 with no mapping. The integrity gate must detect this
// and stop the whole sync with a "Model validation failed" error — before any model is written.
await expect(pushModels([renamedModel, reusedNameModel], [targetModel])).rejects.toThrow(/Model validation failed/);

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