From 431f9ff0590e5507d90fac53869dcf8b8df6f67e Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 30 Sep 2026 21:19:16 +0200 Subject: [PATCH] Normalize external model state comparisons --- ...ow-up-import-project-command.repository.ts | 3 ++- ...itial-import-project-command.repository.ts | 5 ++-- .../domain/external-model-state-comparison.ts | 27 +++++++++++++++++++ ...-import-project-command.repository.test.ts | 1 + tests/external-xlsx-initial-import.test.ts | 1 + 5 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/external-model/domain/external-model-state-comparison.ts diff --git a/src/db/repositories/external-follow-up-import-project-command.repository.ts b/src/db/repositories/external-follow-up-import-project-command.repository.ts index f0b3078..e87e1af 100644 --- a/src/db/repositories/external-follow-up-import-project-command.repository.ts +++ b/src/db/repositories/external-follow-up-import-project-command.repository.ts @@ -6,6 +6,7 @@ import type { ExternalFollowUpImportProjectCommandStore } from "../../domain/por import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js"; import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import { externalModelStatesEqual } from "../../external-model/domain/external-model-state-comparison.js"; import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; @@ -30,7 +31,7 @@ export class ExternalFollowUpImportProjectCommandRepository implements ExternalF return executeProjectCommandTransaction(this.database, input, (tx) => { const currentState = new ExternalModelStateRepository(tx).getByProject(input.projectId); const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get(); - if (!currentState.projectExists || !currentConfiguration || !same(currentState.state, input.command.payload.expectedState) || !same(currentConfiguration, input.command.payload.expectedConfiguration)) throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert."); + if (!currentState.projectExists || !currentConfiguration || !externalModelStatesEqual(currentState.state, input.command.payload.expectedState) || !same(currentConfiguration, input.command.payload.expectedConfiguration)) throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert."); const rowIds = input.command.payload.rows.map(({ expected }) => expected.id); const currentRows = rowIds.length ? tx.select().from(circuitDeviceRows).where(inArray(circuitDeviceRows.id, rowIds)).all() : []; if (currentRows.length !== rowIds.length) throw new Error("Eine Gerätezeile des Folgeimports wurde nicht gefunden."); diff --git a/src/db/repositories/external-initial-import-project-command.repository.ts b/src/db/repositories/external-initial-import-project-command.repository.ts index ede8875..501cb10 100644 --- a/src/db/repositories/external-initial-import-project-command.repository.ts +++ b/src/db/repositories/external-initial-import-project-command.repository.ts @@ -8,6 +8,7 @@ import { import type { ExternalModelConfigurationSnapshot } from "../../domain/models/external-model-configuration-project-command.model.js"; import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js"; import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import { externalModelStatesEqual } from "../../external-model/domain/external-model-state-comparison.js"; import type { AppDatabase } from "../database-context.js"; import { distributionBoards } from "../schema/distribution-boards.js"; import { externalImportBatches } from "../schema/external-import-batches.js"; @@ -32,7 +33,7 @@ export class ExternalInitialImportProjectCommandRepository implements ExternalIn if (!currentStateResult.projectExists) throw new Error("Projekt nicht gefunden."); const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null; const payload = input.command.payload; - if (!same(payload.expectedState, currentStateResult.state) || !same(payload.expectedConfiguration, currentConfiguration)) { + if (!externalModelStatesEqual(payload.expectedState, currentStateResult.state) || !same(payload.expectedConfiguration, currentConfiguration)) { throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert."); } if (!isEmptyExternalModelState(payload.targetState)) validateTarget(tx, input.projectId, input.expectedRevision, input.source, payload.targetConfiguration, payload.targetState); @@ -40,7 +41,7 @@ export class ExternalInitialImportProjectCommandRepository implements ExternalIn replaceState(tx, input.projectId, payload.targetState); const persistedState = new ExternalModelStateRepository(tx).getByProject(input.projectId).state; const persistedConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null; - if (!same(payload.targetState, persistedState) || !same(payload.targetConfiguration, persistedConfiguration)) throw new Error("Gespeicherter Revit-Erstimport weicht vom Zielzustand ab."); + if (!externalModelStatesEqual(payload.targetState, persistedState) || !same(payload.targetConfiguration, persistedConfiguration)) throw new Error("Gespeicherter Revit-Erstimport weicht vom Zielzustand ab."); return createExternalInitialImportProjectCommand(payload.targetConfiguration, currentConfiguration, payload.targetState, currentStateResult.state); }); } diff --git a/src/external-model/domain/external-model-state-comparison.ts b/src/external-model/domain/external-model-state-comparison.ts new file mode 100644 index 0000000..ff851f0 --- /dev/null +++ b/src/external-model/domain/external-model-state-comparison.ts @@ -0,0 +1,27 @@ +import type { ExternalModelStateSnapshot } from "./external-model-contracts.js"; + +export function externalModelStatesEqual(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) { + return canonical(normalize(left)) === canonical(normalize(right)); +} + +function normalize(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot { + return { + ...state, + importBatches: [...state.importBatches].sort((left, right) => compareNullableNumber(left.appliedProjectRevision, right.appliedProjectRevision) || compareText(left.id, right.id)), + roomMappings: [...state.roomMappings].sort((left, right) => compareText(left.normalizedSourceRoomKey, right.normalizedSourceRoomKey) || compareText(left.id, right.id)), + objects: [...state.objects].sort((left, right) => compareText(left.worksharingId, right.worksharingId) || compareText(left.id, right.id)), + }; +} + +function compareNullableNumber(left: number | null, right: number | null) { + if (left === right) return 0; + if (left === null) return -1; + if (right === null) return 1; + return left - right; +} +function compareText(left: string, right: string) { return left < right ? -1 : left > right ? 1 : 0; } +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value !== null && typeof value === "object") { const record = value as Record; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } + return JSON.stringify(value); +} diff --git a/tests/external-follow-up-import-project-command.repository.test.ts b/tests/external-follow-up-import-project-command.repository.test.ts index 8d4acf4..fcb6ae7 100644 --- a/tests/external-follow-up-import-project-command.repository.test.ts +++ b/tests/external-follow-up-import-project-command.repository.test.ts @@ -61,6 +61,7 @@ describe("external follow-up import project command", () => { const followParsed = await createExternalXlsxFollowUpImportPlan({ fileName: "follow.xlsx", bytes: followBytes, configuration, catalogs, currentState: current, currentReturnValuesByObjectId: returnValues }); const expectedRow = toCircuitDeviceRowSnapshot(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!); const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(followBytes).toString("base64"), document: followParsed.document, plan: followParsed.plan, currentState: current, sourceChangeDecisions: followParsed.plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: [], familyProjectDeviceDecisions: [], objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([["row-1", expectedRow]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); + built.targetState.objects.reverse(); const repository = new ExternalFollowUpImportProjectCommandRepository(context.db); const commandWithoutRequiredRowTransition = createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: current, targetState: built.targetState, rows: [] }); assert.throws( diff --git a/tests/external-xlsx-initial-import.test.ts b/tests/external-xlsx-initial-import.test.ts index 75af423..df17e23 100644 --- a/tests/external-xlsx-initial-import.test.ts +++ b/tests/external-xlsx-initial-import.test.ts @@ -57,6 +57,7 @@ describe("Linear XLSX initial import", () => { const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: "linear.xlsx", bytes, configuration, catalogs: { floors: [], rooms: [], distributionBoards: [], projectDevices: [] } }); let sequence = 0; const target = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T10:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, roomDecisions: plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); + target.objects.reverse(); const repository = new ExternalInitialImportProjectCommandRepository(context.db); const inserted = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), target) }); assert.equal(inserted.revision.revisionNumber, 1);