From 347b3717c31b8360d02945b2af0f1fd75bbf57c9 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 2 Aug 2026 18:44:46 +0200 Subject: [PATCH] Add external object new row command --- AGENTS.md | 5 + docs/circuit-list-editor-known-limitations.md | 7 +- docs/current-architecture.md | 8 + .../spec/revit-csv-phase-14-audit-and-plan.md | 7 +- ...ject-new-row-project-command.repository.ts | 266 ++++++++++++++++++ ...al-object-new-row-project-command.model.ts | 163 +++++++++++ ...al-object-new-row-project-command.store.ts | 19 ++ .../services/project-command.service.ts | 20 +- .../composition/project-command-stores.ts | 6 +- ...ignment-project-command.repository.test.ts | 157 ++++++++++- 10 files changed, 649 insertions(+), 9 deletions(-) create mode 100644 src/db/repositories/external-object-new-row-project-command.repository.ts create mode 100644 src/domain/models/external-object-new-row-project-command.model.ts create mode 100644 src/domain/ports/external-object-new-row-project-command.store.ts diff --git a/AGENTS.md b/AGENTS.md index 4b13c4e..6332166 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -294,6 +294,11 @@ the manual quantity, validates the materialized total against every target link, rejects project/board/category/room/selection-marker mixing and requires explicitly listed confirmations for other planning-value conflicts. Its exact inverse supports persistent Undo/Redo, and detach never deletes the row. +`external-object.assign-to-new-row` atomically inserts unassigned external +objects as one external-only row (`manualQuantity = 0`) into an existing +circuit. Its history-only inverse removes the row only while its complete +snapshot and exact object-link set remain unchanged. Ordinary detach continues +to preserve the row. Confirmed initial state is written only through `external-import.apply-initial`. The command rechecks configuration version, original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 0c0ec21..0a7b076 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -10,9 +10,10 @@ through the project page with persistent Undo/Redo. Automatic snapshots are created every 25 revisions and only their newest 12 entries are retained. - The Revit/CSV foundation and confirmed initial import are implemented. - Existing-row assignment, move and detach have an atomic persistent command; - their editor UI, new-row/new-circuit paths, follow-up import, conflict review - and return export are pending. + Existing-row assignment, move and detach and creation of an external-only row + in an existing circuit have atomic persistent commands. Their editor UI, the + new-circuit path, follow-up import, conflict review and return export are + pending. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - The global device library supports basic CRUD and copy operations, but has no versioning, permissions or controlled synchronization model. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 86b11f4..fb4f2ee 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -551,6 +551,14 @@ still zusammengeführt. Alle betroffenen materialisierten Mengen werden aus dem unveränderten manuellen Anteil und sämtlichen Ziel-Objektlinks validiert. Die Gegenrichtung enthält dieselben vollständigen Snapshots und funktioniert nach Neustart; beim Lösen des letzten Objekts bleibt die Row bestehen. +`external-object.assign-to-new-row` fügt unzugeordnete Objekte atomar als neue +Gerätezeile in einen vorhandenen Stromkreis ein. Der manuelle Anteil dieser Row +ist null; Gesamtmenge, Raum, Verteilung, Kategorie, Selektionskennzeichen und +Planungswerte werden vor dem Schreiben geprüft. Undo löst exakt diese Objekte +und löscht die erzeugte Row nur, wenn sie und ihre Linkmenge unverändert sind. +Der Löschbefehl ist ausschließlich für die Projekthistorie zugelassen; eine +normale spätere Trennung lässt die Row gemäß der allgemeinen Zuordnungsregel +bestehen. `GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder ändern die Konfiguration; der PUT plant Identität und nächsten Konfigurationsstand serverseitig und verwendet den typisierten Command. diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md index f193d29..ffc1b45 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -325,7 +325,12 @@ Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt. Planungsabweichungen eine explizite Objektliste und aktualisiert Links und Gesamtmengen in einer SQLite-Transaktion. Seine exakte Gegenrichtung ist dauerhaft Undo/Redo-fähig. Beim Lösen bleibt die Row mit ihrem manuellen - Mengenanteil bestehen. Neue Row und neuer Circuit folgen separat. + Mengenanteil bestehen. `external-object.assign-to-new-row` ergänzt die + Zuordnung ungebundener Objekte als neue Row in einem vorhandenen Circuit. + Diese Row erhält eine stabile ID und `manualQuantity = 0`; ihr historischer + Gegenbefehl entfernt sie nur, solange Row und Objektlinks vollständig + unverändert sind. Ein direktes Löschen über diesen internen Gegenbefehl ist + als Nutzeraktion gesperrt. Der neue Circuit folgt separat. 3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen. 4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload testen. diff --git a/src/db/repositories/external-object-new-row-project-command.repository.ts b/src/db/repositories/external-object-new-row-project-command.repository.ts new file mode 100644 index 0000000..d2db69c --- /dev/null +++ b/src/db/repositories/external-object-new-row-project-command.repository.ts @@ -0,0 +1,266 @@ +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js"; +import { + assertExternalObjectNewRowProjectCommand, + externalObjectAssignToNewRowCommandType, + externalObjectDeleteCreatedRowCommandType, + invertExternalObjectNewRowProjectCommand, + type ExternalObjectNewRowProjectCommand, +} from "../../domain/models/external-object-new-row-project-command.model.js"; +import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; +import type { ExternalObjectNewRowProjectCommandStore } from "../../domain/ports/external-object-new-row-project-command.store.js"; +import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js"; +import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitSections } from "../schema/circuit-sections.js"; +import { circuits } from "../schema/circuits.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; +import { externalRoomMappings } from "../schema/external-room-mappings.js"; +import { + assertCircuitDeviceRowReferencesInProject, + toCircuitDeviceRowInsertValues, + toCircuitDeviceRowSnapshot, +} from "./circuit-device-row-structure.persistence.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; + +export class ExternalObjectNewRowProjectCommandRepository + implements ExternalObjectNewRowProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: Parameters[0]) { + assertExternalObjectNewRowProjectCommand(input.command); + if ( + input.source === "user" && + input.command.type === externalObjectDeleteCreatedRowCommandType + ) { + throw new Error("Created external rows may only be removed through project history."); + } + return executeProjectCommandTransaction(this.database, input, (tx) => { + if (input.command.type === externalObjectAssignToNewRowCommandType) { + this.insert(tx, input.projectId, input.command); + } else { + this.remove(tx, input.projectId, input.command); + } + return invertExternalObjectNewRowProjectCommand(input.command); + }); + } + + private insert( + database: AppDatabase, + projectId: string, + command: ExternalObjectNewRowProjectCommand + ) { + const row = command.payload.row; + const context = this.loadCircuitContext(database, projectId, row.circuitId); + if (!isElectricalPhaseType(row.phaseType)) { + throw new Error("External device-row phase type is invalid."); + } + if (row.category !== context.category) { + throw new Error("External device-row category does not match target circuit group."); + } + assertCircuitDeviceRowReferencesInProject(database, projectId, row); + if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, row.id)).get()) { + throw new Error("External device-row id already exists."); + } + const objects = this.loadExpectedObjects(database, projectId, command); + this.assertTargetCompatibility( + database, + row, + context.distributionBoardId, + context.category, + objects.map((object) => object.target), + new Set(command.payload.confirmedConflictObjectIds) + ); + assertCircuitDeviceRowQuantity({ + quantity: row.quantity, + manualQuantity: 0, + externalObjects: objects.map(({ target }) => ({ + effectiveQuantity: target.planningValues.effectiveQuantity, + })), + }); + + database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run(); + this.updateCircuitState(database, projectId, row.circuitId); + this.applyObjectLinks(database, objects); + } + + private remove( + database: AppDatabase, + projectId: string, + command: ExternalObjectNewRowProjectCommand + ) { + const expectedRow = command.payload.row; + this.loadCircuitContext(database, projectId, expectedRow.circuitId); + const currentRow = database.select().from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, expectedRow.id)).get(); + if (!currentRow || !same(toCircuitDeviceRowSnapshot(currentRow), expectedRow)) { + throw new Error("Created external device row changed before history removal."); + } + const objects = this.loadExpectedObjects(database, projectId, command); + const linkedObjects = database.select({ id: externalModelObjects.id }) + .from(externalModelObjects) + .where(eq(externalModelObjects.circuitDeviceRowId, expectedRow.id)).all(); + if ( + linkedObjects.length !== objects.length || + linkedObjects.some(({ id }) => !objects.some((object) => object.expected.id === id)) + ) { + throw new Error("Created external device row has different object links."); + } + this.applyObjectLinks(database, objects); + const deleted = database.delete(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, expectedRow.id)).run(); + if (deleted.changes !== 1) { + throw new Error("Created external device row changed during history removal."); + } + this.updateCircuitState(database, projectId, expectedRow.circuitId); + } + + private loadCircuitContext(database: AppDatabase, projectId: string, circuitId: string) { + const context = database.select({ + projectId: circuitLists.projectId, + distributionBoardId: circuitLists.distributionBoardId, + category: circuitSections.category, + }).from(circuits) + .innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)) + .innerJoin(circuitSections, eq(circuitSections.id, circuits.sectionId)) + .where(eq(circuits.id, circuitId)).get(); + if (!context || context.projectId !== projectId || context.category === null) { + throw new Error("Target circuit does not belong to the project."); + } + return { + projectId: context.projectId, + distributionBoardId: context.distributionBoardId, + category: context.category, + }; + } + + private loadExpectedObjects( + database: AppDatabase, + projectId: string, + command: ExternalObjectNewRowProjectCommand + ) { + const transitions = new Map( + command.payload.objects.map((transition) => [transition.expected.id, transition]) + ); + const current = database.select().from(externalModelObjects) + .where(inArray(externalModelObjects.id, [...transitions.keys()])).all(); + if (current.length !== transitions.size) { + throw new Error("One or more external objects no longer exist."); + } + for (const object of current) { + const expected = transitions.get(object.id)!.expected; + if (object.projectId !== projectId || !same(toExternalObjectSnapshot(object), expected)) { + throw new Error("External object changed before new-row assignment."); + } + } + return command.payload.objects; + } + + private assertTargetCompatibility( + database: AppDatabase, + row: CircuitDeviceRowSnapshot, + distributionBoardId: string, + category: string, + objects: ExternalModelObjectSnapshot[], + confirmedConflicts: Set + ) { + const roomMappings = database.select().from(externalRoomMappings) + .where(eq(externalRoomMappings.projectId, objects[0]!.projectId)).all(); + const roomIdByMappingId = new Map(roomMappings.map((mapping) => [mapping.id, mapping.roomId])); + const selectionMarkers = new Set( + objects.map((object) => object.acceptedSourceValues.selectionMarker.trim()) + ); + if (selectionMarkers.size > 1) { + throw new Error("External objects with different selection markers require separate rows."); + } + for (const object of objects) { + if (object.distributionBoardId !== distributionBoardId) { + throw new Error("External object distribution does not match target circuit."); + } + if (object.planningValues.category !== category) { + throw new Error("External object category does not match target circuit group."); + } + const objectRoomId = object.externalRoomMappingId === null + ? null + : roomIdByMappingId.get(object.externalRoomMappingId) ?? null; + if (objectRoomId !== row.roomId) { + throw new Error("External object room does not match the new device row."); + } + if (!matchesRowPlanningValues(row, object) && !confirmedConflicts.has(object.id)) { + throw new Error("External object planning values require explicit conflict confirmation."); + } + } + } + + private applyObjectLinks( + database: AppDatabase, + objects: ExternalObjectNewRowProjectCommand["payload"]["objects"] + ) { + for (const { expected, target } of objects) { + const result = database.update(externalModelObjects) + .set({ circuitDeviceRowId: target.circuitDeviceRowId }) + .where(and( + eq(externalModelObjects.id, expected.id), + expected.circuitDeviceRowId === null + ? isNull(externalModelObjects.circuitDeviceRowId) + : eq(externalModelObjects.circuitDeviceRowId, expected.circuitDeviceRowId) + )).run(); + if (result.changes !== 1) { + throw new Error("External object changed during new-row assignment."); + } + } + } + + private updateCircuitState(database: AppDatabase, projectId: string, circuitId: string) { + const remaining = database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, circuitId)).limit(1).get(); + const result = database.update(circuits).set({ isReserve: remaining ? 0 : 1 }) + .where(eq(circuits.id, circuitId)).run(); + if (result.changes !== 1) throw new Error("Target circuit changed during row assignment."); + updateDerivedCircuitVoltage(database, projectId, circuitId); + } +} + +function matchesRowPlanningValues( + row: CircuitDeviceRowSnapshot, + object: ExternalModelObjectSnapshot +) { + const planning = object.planningValues; + return ( + row.displayName === (planning.displayName ?? row.displayName) && + row.linkedProjectDeviceId === object.linkedProjectDeviceId && + row.category === planning.category && + row.connectionKind === planning.connectionKind && + (planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) && + row.simultaneityFactor === planning.simultaneityFactor && + row.cosPhi === planning.cosPhi && + row.costGroup === planning.costGroup && + row.remark === planning.remark + ); +} + +function toExternalObjectSnapshot( + object: typeof externalModelObjects.$inferSelect +): ExternalModelObjectSnapshot { + return { ...object }; +} + +function same(left: unknown, right: unknown) { + return canonicalJson(left) === canonicalJson(right); +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value !== null && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => + `${JSON.stringify(key)}:${canonicalJson(record[key])}` + ).join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/src/domain/models/external-object-new-row-project-command.model.ts b/src/domain/models/external-object-new-row-project-command.model.ts new file mode 100644 index 0000000..d0416dd --- /dev/null +++ b/src/domain/models/external-object-new-row-project-command.model.ts @@ -0,0 +1,163 @@ +import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import { + assertCircuitDeviceRowInsertProjectCommand, + circuitDeviceRowInsertCommandType, + circuitDeviceRowStructureCommandSchemaVersion, + type CircuitDeviceRowSnapshot, +} from "./circuit-device-row-structure-project-command.model.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; +import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js"; + +export const externalObjectAssignToNewRowCommandType = + "external-object.assign-to-new-row" as const; +export const externalObjectDeleteCreatedRowCommandType = + "external-object.unassign-and-delete-created-row" as const; +export const externalObjectNewRowCommandSchemaVersion = 1 as const; + +export interface ExternalObjectNewRowPayload { + row: CircuitDeviceRowSnapshot; + objects: Array<{ + expected: ExternalModelObjectSnapshot; + target: ExternalModelObjectSnapshot; + }>; + confirmedConflictObjectIds: string[]; +} + +export interface ExternalObjectNewRowProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof externalObjectNewRowCommandSchemaVersion; + type: + | typeof externalObjectAssignToNewRowCommandType + | typeof externalObjectDeleteCreatedRowCommandType; +} + +export function createExternalObjectNewRowProjectCommand( + type: ExternalObjectNewRowProjectCommand["type"], + payload: ExternalObjectNewRowPayload +): ExternalObjectNewRowProjectCommand { + const command: ExternalObjectNewRowProjectCommand = { + schemaVersion: externalObjectNewRowCommandSchemaVersion, + type, + payload: { + row: { + ...payload.row, + manualQuantity: payload.row.manualQuantity ?? payload.row.quantity, + }, + objects: payload.objects, + confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds], + }, + }; + assertExternalObjectNewRowProjectCommand(command); + return command; +} + +export function invertExternalObjectNewRowProjectCommand( + command: ExternalObjectNewRowProjectCommand +) { + return createExternalObjectNewRowProjectCommand( + command.type === externalObjectAssignToNewRowCommandType + ? externalObjectDeleteCreatedRowCommandType + : externalObjectAssignToNewRowCommandType, + { + row: command.payload.row, + objects: command.payload.objects.map(({ expected, target }) => ({ + expected: target, + target: expected, + })), + confirmedConflictObjectIds: command.payload.confirmedConflictObjectIds, + } + ); +} + +export function assertExternalObjectNewRowProjectCommand( + command: SerializedProjectCommand +): asserts command is ExternalObjectNewRowProjectCommand { + if ( + command.schemaVersion !== externalObjectNewRowCommandSchemaVersion || + (command.type !== externalObjectAssignToNewRowCommandType && + command.type !== externalObjectDeleteCreatedRowCommandType) || + !isRecord(command.payload) || + !Array.isArray(command.payload.objects) || + command.payload.objects.length === 0 || + !Array.isArray(command.payload.confirmedConflictObjectIds) + ) { + throw new Error("Unsupported external object new-row command."); + } + const row = parseRow(command.payload.row); + if (row.manualQuantity !== 0) { + throw new Error("An external-only device row must start with zero manual quantity."); + } + const objectIds = new Set(); + for (const transition of command.payload.objects) { + if (!isRecord(transition)) throw new Error("Invalid external object transition."); + const expected = parseObject(transition.expected); + const target = parseObject(transition.target); + if (expected.id !== target.id || objectIds.has(expected.id)) { + throw new Error("New-row assignment contains mismatched or duplicate objects."); + } + objectIds.add(expected.id); + const assigning = command.type === externalObjectAssignToNewRowCommandType; + if ( + (assigning && (expected.circuitDeviceRowId !== null || target.circuitDeviceRowId !== row.id)) || + (!assigning && (expected.circuitDeviceRowId !== row.id || target.circuitDeviceRowId !== null)) + ) { + throw new Error("External object links do not match the new-row action."); + } + assertOnlyAssignmentChanged(expected, target); + } + const confirmedIds = new Set(); + for (const objectId of command.payload.confirmedConflictObjectIds) { + if (typeof objectId !== "string" || !objectIds.has(objectId) || confirmedIds.has(objectId)) { + throw new Error("New-row assignment contains an invalid conflict confirmation."); + } + confirmedIds.add(objectId); + } +} + +function parseRow(value: unknown): CircuitDeviceRowSnapshot { + const envelope = { + schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, + type: circuitDeviceRowInsertCommandType, + payload: { row: value }, + }; + assertCircuitDeviceRowInsertProjectCommand(envelope); + const row = envelope.payload.row; + if (row.manualQuantity === undefined) { + throw new Error("External object new-row command requires manual quantity."); + } + return row; +} + +function parseObject(value: unknown): ExternalModelObjectSnapshot { + return parseExternalModelStateSnapshot({ + source: null, + importBatches: [], + roomMappings: [], + objects: [value], + }).objects[0]!; +} + +function assertOnlyAssignmentChanged( + expected: ExternalModelObjectSnapshot, + target: ExternalModelObjectSnapshot +) { + const expectedRest = { ...expected, circuitDeviceRowId: null }; + const targetRest = { ...target, circuitDeviceRowId: null }; + if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) { + throw new Error("New-row assignment may only change the external object row link."); + } +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (isRecord(value)) { + return `{${Object.keys(value).sort().map((key) => + `${JSON.stringify(key)}:${canonicalJson(value[key])}` + ).join(",")}}`; + } + return JSON.stringify(value); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/external-object-new-row-project-command.store.ts b/src/domain/ports/external-object-new-row-project-command.store.ts new file mode 100644 index 0000000..cd13369 --- /dev/null +++ b/src/domain/ports/external-object-new-row-project-command.store.ts @@ -0,0 +1,19 @@ +import type { ExternalObjectNewRowProjectCommand } from "../models/external-object-new-row-project-command.model.js"; +import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; + +export interface ExecuteExternalObjectNewRowCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: ExternalObjectNewRowProjectCommand; +} + +export interface ExternalObjectNewRowProjectCommandStore { + execute(input: ExecuteExternalObjectNewRowCommandInput): { + revision: AppendedProjectRevision; + inverse: ExternalObjectNewRowProjectCommand; + }; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 2a90470..be66428 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -176,6 +176,12 @@ import { externalObjectRowAssignmentCommandType, } from "../models/external-object-row-assignment-project-command.model.js"; import type { ExternalObjectRowAssignmentProjectCommandStore } from "../ports/external-object-row-assignment-project-command.store.js"; +import { + assertExternalObjectNewRowProjectCommand, + externalObjectAssignToNewRowCommandType, + externalObjectDeleteCreatedRowCommandType, +} from "../models/external-object-new-row-project-command.model.js"; +import type { ExternalObjectNewRowProjectCommandStore } from "../ports/external-object-new-row-project-command.store.js"; interface DispatchProjectCommandInput { projectId: string; @@ -213,7 +219,8 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly historyStore: ProjectHistoryStore, private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore, private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, - private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore + private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore, + private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore ) {} executeUser( @@ -600,6 +607,17 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case externalObjectAssignToNewRowCommandType: + case externalObjectDeleteCreatedRowCommandType: { + assertExternalObjectNewRowProjectCommand(input.command); + if (!this.externalObjectNewRowStore) { + throw new Error("External object new-row store is not available."); + } + return this.externalObjectNewRowStore.execute({ + ...input, + command: input.command, + }).revision; + } case projectStateRestoreCommandType: { assertProjectStateRestoreCommand(input.command); return this.projectStateRestoreStore.execute({ diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 9b6d539..9e63ed6 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -25,6 +25,7 @@ import { ProjectCommandService } from "../../domain/services/project-command.ser import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-configuration-project-command.repository.js"; import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js"; import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js"; +import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js"; export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); export const circuitDeviceRowProjectCommandStore = @@ -74,6 +75,8 @@ export const externalInitialImportProjectCommandStore = new ExternalInitialImportProjectCommandRepository(db); export const externalObjectRowAssignmentProjectCommandStore = new ExternalObjectRowAssignmentProjectCommandRepository(db); +export const externalObjectNewRowProjectCommandStore = + new ExternalObjectNewRowProjectCommandRepository(db); export const projectCommandService = new ProjectCommandService( circuitProjectCommandStore, circuitDeviceRowProjectCommandStore, @@ -99,5 +102,6 @@ export const projectCommandService = new ProjectCommandService( projectHistoryStore, externalCsvConfigurationProjectCommandStore, externalInitialImportProjectCommandStore, - externalObjectRowAssignmentProjectCommandStore + externalObjectRowAssignmentProjectCommandStore, + externalObjectNewRowProjectCommandStore ); diff --git a/tests/external-object-row-assignment-project-command.repository.test.ts b/tests/external-object-row-assignment-project-command.repository.test.ts index 5585204..4e127f1 100644 --- a/tests/external-object-row-assignment-project-command.repository.test.ts +++ b/tests/external-object-row-assignment-project-command.repository.test.ts @@ -5,6 +5,7 @@ import { eq } from "drizzle-orm"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createDatabaseContext, type AppDatabase } from "../src/db/database-context.js"; import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js"; +import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js"; import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; @@ -19,10 +20,14 @@ import { floors } from "../src/db/schema/floors.js"; import { projects } from "../src/db/schema/projects.js"; import { rooms } from "../src/db/schema/rooms.js"; import { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js"; +import { + createExternalObjectNewRowProjectCommand, + externalObjectAssignToNewRowCommandType, +} from "../src/domain/models/external-object-new-row-project-command.model.js"; import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js"; import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js"; -function createFixture() { +function createFixture(initialObjectRowId: string | null = "row-1") { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); const database = context.db; @@ -46,7 +51,7 @@ function createFixture() { roomId: "room-1", roomNumberSnapshot: "101", roomNameSnapshot: "Büro", - quantity: index === 1 ? 3 : 0, + quantity: index === 1 ? (initialObjectRowId === "row-1" ? 3 : 1) : 0, manualQuantity: index === 1 ? 1 : 0, powerPerUnit: 0.12, simultaneityFactor: 1, @@ -78,7 +83,7 @@ function createFixture() { roomId: "room-1", defaultDistributionBoardId: "board-1", }).run(); - database.insert(externalModelObjects).values(objectSnapshot("row-1")).run(); + database.insert(externalModelObjects).values(objectSnapshot(initialObjectRowId)).run(); return context; } @@ -225,3 +230,149 @@ describe("external object row assignment project command", () => { } }); }); + +describe("external object new-row project command", () => { + it("creates an external-only row and supports exact persisted undo and redo", () => { + const context = createFixture(null); + try { + const base = row(context.db, "row-2"); + const newRow = { + ...base, + id: "row-new", + quantity: 2, + manualQuantity: 0, + sortOrder: 20, + }; + const object = objectSnapshot(null); + const command = createExternalObjectNewRowProjectCommand( + externalObjectAssignToNewRowCommandType, + { + row: newRow, + objects: [{ + expected: object, + target: { ...object, circuitDeviceRowId: "row-new" }, + }], + confirmedConflictObjectIds: [], + } + ); + const repository = new ExternalObjectNewRowProjectCommandRepository(context.db); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.equal(row(context.db, "row-new").manualQuantity, 0); + assert.equal(row(context.db, "row-new").quantity, 2); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new"); + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: inserted.inverse, + }), + /only be removed through project history/ + ); + + const undone = repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command: inserted.inverse, + }); + assert.equal( + context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-new")).get(), + undefined + ); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); + + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command: undone.inverse, + }); + assert.equal(row(context.db, "row-new").quantity, 2); + } finally { + context.close(); + } + }); + + it("rolls back the row and object link when history persistence fails", () => { + const context = createFixture(null); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_new_row_history + BEFORE INSERT ON project_change_sets + BEGIN SELECT RAISE(ABORT, 'late history failure'); END; + `); + const base = row(context.db, "row-2"); + const object = objectSnapshot(null); + const command = createExternalObjectNewRowProjectCommand( + externalObjectAssignToNewRowCommandType, + { + row: { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20 }, + objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" } }], + confirmedConflictObjectIds: [], + } + ); + assert.throws( + () => new ExternalObjectNewRowProjectCommandRepository(context.db).execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }), + /late history failure/ + ); + assert.equal( + context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-new")).get(), + undefined + ); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); + } finally { + context.close(); + } + }); + + it("keeps the created row when it changed after assignment", () => { + const context = createFixture(null); + try { + const base = row(context.db, "row-2"); + const object = objectSnapshot(null); + const repository = new ExternalObjectNewRowProjectCommandRepository(context.db); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createExternalObjectNewRowProjectCommand( + externalObjectAssignToNewRowCommandType, + { + row: { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20 }, + objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" } }], + confirmedConflictObjectIds: [], + } + ), + }); + context.db.update(circuitDeviceRows).set({ displayName: "Geändert" }) + .where(eq(circuitDeviceRows.id, "row-new")).run(); + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command: inserted.inverse, + }), + /changed before history removal/ + ); + assert.equal(row(context.db, "row-new").displayName, "Geändert"); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new"); + } finally { + context.close(); + } + }); +});