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
92 changes: 68 additions & 24 deletions src/lib/pushers/model-pusher.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -10,7 +10,7 @@ import { Logs } from "core/logs";
export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtApi.Model[]): Promise<PusherResult> {
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.");
Expand All @@ -24,18 +24,46 @@ 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 = [];
let shouldSkip = [];
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;
Expand All @@ -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);
Expand All @@ -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++;
}

Expand All @@ -121,6 +165,7 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp
successful,
failed,
skipped,
failureDetails,
};
}

Expand All @@ -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
Expand All @@ -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";
}
};
Expand All @@ -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) {
Expand All @@ -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";
}
}
88 changes: 44 additions & 44 deletions src/lib/pushers/tests/model-pusher.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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]);

Expand All @@ -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], []);

Expand All @@ -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], []);

Expand Down
Loading