diff --git a/src/lib/pushers/model-pusher.ts b/src/lib/pushers/model-pusher.ts index 8f797f7..0c16b03 100644 --- a/src/lib/pushers/model-pusher.ts +++ b/src/lib/pushers/model-pusher.ts @@ -1,6 +1,6 @@ import * as mgmtApi from "@agility/management-sdk"; import { getApiClient, state, getLoggerForGuid } from "../../core/state"; -import { PusherResult } from "../../types/sourceData"; +import { PusherResult, FailureDetail } from "../../types/sourceData"; import { ModelMapper } from "lib/mappers/model-mapper"; import { Logs } from "core/logs"; @@ -10,7 +10,7 @@ import { Logs } from "core/logs"; export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtApi.Model[]): Promise { const models: mgmtApi.Model[] = sourceData || []; const { sourceGuid, targetGuid } = state; - const logger = getLoggerForGuid(sourceGuid[0]); + const logger = getLoggerForGuid(sourceGuid[0])!; if (!models || models.length === 0) { logger.log("INFO", "No models found to process."); @@ -24,6 +24,7 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp let successful = 0; let failed = 0; let skipped = 0; + const failureDetails: FailureDetail[] = []; let shouldCreateStub = []; let shouldUpdateFields = []; @@ -31,11 +32,38 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp let stubCreated = []; for (const model of models) { - const mapping = referenceMapper.getModelMapping(model, "source"); + if (!model.id || !model.referenceName) { + logger.model.error(model, "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 modelLastModifiedDate = new Date(model.lastModifiedDate); const targetLastModifiedDate = targetModel ? new Date(targetModel.lastModifiedDate) : null; - const mappingLastModifiedDate = mapping ? new Date(mapping.targetLastModifiedDate) : null; + const mappingLastModifiedDate = sourceMapping ? new Date(sourceMapping.targetLastModifiedDate) : null; const hasSourceChanged = modelLastModifiedDate > targetLastModifiedDate; const hasTargetChanged = targetLastModifiedDate > mappingLastModifiedDate; const sourceFieldCount = model?.fields?.length || 0; @@ -44,10 +72,9 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp // TODO: we only care about the field count if the target model has NO fields and the source model has fields - // Handle models that exist in target but have no mapping // This ensures downstream containers can find their model mappings - const existsInTargetWithoutMapping = !mapping && targetModel; + const existsInTargetWithoutMapping = !sourceMapping && targetModel; if (existsInTargetWithoutMapping) { // Create the mapping for existing target models (ensures containers can reference them) referenceMapper.addMapping(model, targetModel); @@ -56,63 +83,80 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp continue; // Skip remaining conditions - mapping is now created, no further action needed } - if ((!mapping && !targetModel)) { + if (!sourceMapping && !targetModel) { shouldCreateStub.push(model); 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 ((mapping && hasSourceChanged) || (mapping && fieldCountChanged)) { + if ((sourceMapping && hasSourceChanged) || (sourceMapping && fieldCountChanged)) { shouldUpdateFields.push(model); continue; } - if (mapping && (hasTargetChanged || hasSourceChanged) && state.overwrite) { + if (sourceMapping && (hasTargetChanged || hasSourceChanged) && state.overwrite) { shouldUpdateFields.push(model); continue; } // if the mapping exists, and the target has changed, we need to skip the model, not safe to update - if (mapping && hasTargetChanged) { + if (sourceMapping && hasTargetChanged) { shouldSkip.push(model); continue; } // if the mapping exists, and the source and target have not changed, we need to skip the model - if (mapping && !hasSourceChanged && !hasTargetChanged && !state.overwrite) { + if (sourceMapping && !hasSourceChanged && !hasTargetChanged && !state.overwrite) { shouldSkip.push(model); continue; } - if(mapping && !hasSourceChanged && !hasTargetChanged && state.overwrite){ + if (sourceMapping && !hasSourceChanged && !hasTargetChanged && state.overwrite) { shouldSkip.push(model); continue; } } - for (const model of shouldCreateStub) { const result = await createNewModel(model, referenceMapper, apiClient, targetGuid[0], logger); if (result === "created") { stubCreated.push(model); } else { failed++; + failureDetails.push({ + name: model.referenceName, + error: `Failed to create model "${model.referenceName}" (ID: ${model.id})`, + guid: sourceGuid[0], + }); } } const modelsToUpdate = [...stubCreated, ...shouldUpdateFields]; for (const model of modelsToUpdate) { - const mapping = referenceMapper.getModelMapping(model, "source"); - const result = await updateExistingModel(model, mapping.targetID, referenceMapper, apiClient, targetGuid[0], logger); + const sourceMapping = referenceMapper.getModelMapping(model, "source"); + + const result = await updateExistingModel( + model, + sourceMapping.targetID, + referenceMapper, + apiClient, + targetGuid[0], + logger, + ); if (result) { successful++; } else { failed++; + failureDetails.push({ + name: model.referenceName, + error: `Failed to update model "${model.referenceName}" (target ID: ${sourceMapping.targetID})`, + guid: sourceGuid[0], + }); } } for (const model of shouldSkip) { - logger.model.skipped(model, "up to date, skipping", targetGuid[0]) + logger.model.skipped(model, "up to date, skipping", targetGuid[0]); skipped++; } @@ -121,6 +165,7 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp successful, failed, skipped, + failureDetails, }; } @@ -132,7 +177,7 @@ const createNewModel = async ( referenceMapper: ModelMapper, apiClient: mgmtApi.ApiClient, targetGuid: string, - logger: Logs + logger: Logs, ): Promise<"created" | "updated" | "skipped" | "failed"> => { try { // process the model without fields @@ -143,11 +188,11 @@ const createNewModel = async ( }; const newModel = await apiClient.modelMethods.saveModel(createPayload, targetGuid); - logger.model.created(model, "created", targetGuid) + logger.model.created(model, "created", targetGuid); referenceMapper.addMapping(model, newModel); return "created"; } catch (error: any) { - logger.model.error(model, error, targetGuid) + logger.model.error(model, error, targetGuid); return "failed"; } }; @@ -161,17 +206,16 @@ async function updateExistingModel( referenceMapper: ModelMapper, apiClient: mgmtApi.ApiClient, targetGuid: string, - logger: Logs + logger: Logs, ): Promise<"updated" | "failed"> { - try { const updatePayload = { ...sourceModel, - id: targetID + id: targetID, }; const updatedModel = await apiClient.modelMethods.saveModel(updatePayload, targetGuid); - logger.model.updated(sourceModel, "updated", targetGuid) + logger.model.updated(sourceModel, "updated", targetGuid); referenceMapper.addMapping(sourceModel, updatedModel); return "updated"; } catch (error: any) { @@ -180,7 +224,7 @@ async function updateExistingModel( console.error(` message: ${error?.message}`); console.error(` status: ${axiosErr?.response?.status ?? axiosErr?.status ?? "n/a"}`); console.error(` responseData: ${JSON.stringify(axiosErr?.response?.data ?? axiosErr?.data ?? null, null, 2)}`); - logger.model.error(sourceModel, error, targetGuid) + logger.model.error(sourceModel, error, targetGuid); return "failed"; } } diff --git a/src/lib/pushers/tests/model-pusher.test.ts b/src/lib/pushers/tests/model-pusher.test.ts index a5ab9d2..6fb5429 100644 --- a/src/lib/pushers/tests/model-pusher.test.ts +++ b/src/lib/pushers/tests/model-pusher.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState, state, initializeGuidLogger } from 'core/state'; -import * as stateModule from 'core/state'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState, state, initializeGuidLogger } from "core/state"; +import * as stateModule from "core/state"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-model-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-model-")); }); afterAll(() => { @@ -16,11 +16,11 @@ afterAll(() => { beforeEach(() => { resetState(); - setState({ rootPath: tmpDir, sourceGuid: 'src-model-u', targetGuid: 'tgt-model-u', token: 'test-token' }); - initializeGuidLogger('src-model-u', 'push'); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + setState({ rootPath: tmpDir, sourceGuid: "src-model-u", targetGuid: "tgt-model-u", token: "test-token" }); + initializeGuidLogger("src-model-u", "push"); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -53,58 +53,58 @@ function makeApiClient(saveModelImpl?: jest.Mock): any { // ─── pushModels — empty sourceData guard ────────────────────────────────────── -describe('pushModels — empty sourceData guard', () => { - it('returns success with zeros when sourceData is empty', async () => { - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(makeApiClient()); +describe("pushModels — empty sourceData guard", () => { + it("returns success with zeros when sourceData is empty", async () => { + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient()); - const { pushModels } = await import('../model-pusher'); + const { pushModels } = await import("../model-pusher"); const result = await pushModels([], []); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); expect(result.failed).toBe(0); expect(result.skipped).toBe(0); }); - it('returns success with zeros when sourceData is null', async () => { - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(makeApiClient()); + it("returns success with zeros when sourceData is null", async () => { + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient()); - const { pushModels } = await import('../model-pusher'); + const { pushModels } = await import("../model-pusher"); const result = await pushModels(null as any, []); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); }); }); // ─── pushModels — result shape ──────────────────────────────────────────────── -describe('pushModels — result shape', () => { - it('result has status, successful, failed, skipped fields', async () => { - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(makeApiClient()); +describe("pushModels — result shape", () => { + it("result has status, successful, failed, skipped fields", async () => { + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient()); - const { pushModels } = await import('../model-pusher'); + const { pushModels } = await import("../model-pusher"); const result = await pushModels([], []); - expect(result).toHaveProperty('status'); - expect(result).toHaveProperty('successful'); - expect(result).toHaveProperty('failed'); - expect(result).toHaveProperty('skipped'); + expect(result).toHaveProperty("status"); + expect(result).toHaveProperty("successful"); + expect(result).toHaveProperty("failed"); + expect(result).toHaveProperty("skipped"); }); }); // ─── pushModels — existsInTargetWithoutMapping ──────────────────────────────── -describe('pushModels — model exists in target but no mapping', () => { - it('skips model that already exists in target by referenceName but has no mapping', async () => { +describe("pushModels — model exists in target but no mapping", () => { + 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)); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel)); - const { pushModels } = await import('../model-pusher'); + 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: "shared-model", lastModifiedDate: now }); + const targetModel = makeModel({ id: 42, referenceName: "shared-model", lastModifiedDate: now }); const result = await pushModels([sourceModel], [targetModel]); @@ -117,15 +117,15 @@ describe('pushModels — model exists in target but no mapping', () => { // ─── pushModels — shouldCreateStub path ─────────────────────────────────────── -describe('pushModels — create stub path', () => { - it('calls saveModel to create a stub when model has no mapping and does not exist in target', async () => { +describe("pushModels — create stub path", () => { + it("calls saveModel to create a stub when model has no mapping and does not exist in target", async () => { const createdStub = makeModel({ id: 777 }); const saveModel = jest.fn().mockResolvedValue(createdStub); - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(makeApiClient(saveModel)); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel)); - const { pushModels } = await import('../model-pusher'); + const { pushModels } = await import("../model-pusher"); - const sourceModel = makeModel({ referenceName: 'brand-new-model' }); + const sourceModel = makeModel({ referenceName: "brand-new-model" }); const result = await pushModels([sourceModel], []); @@ -135,13 +135,13 @@ describe('pushModels — create stub path', () => { expect(result.failed).toBe(0); }); - it('counts model as failed when saveModel throws during stub creation', async () => { - const saveModel = jest.fn().mockRejectedValue(new Error('API error')); - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(makeApiClient(saveModel)); + it("counts model as failed when saveModel throws during stub creation", async () => { + const saveModel = jest.fn().mockRejectedValue(new Error("API error")); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel)); - const { pushModels } = await import('../model-pusher'); + const { pushModels } = await import("../model-pusher"); - const sourceModel = makeModel({ referenceName: 'failing-model' }); + const sourceModel = makeModel({ referenceName: "failing-model" }); const result = await pushModels([sourceModel], []);