diff --git a/AGENTS.md b/AGENTS.md index 4db7767..7795438 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -303,10 +303,12 @@ Stored schema-version 1 and 2 setup commands remain executable with their four legacy sections and without invented components. Mutable group-protection and auxiliary distribution-board components use `distribution-board-component.insert` and -`distribution-board-component.delete`. Their complete snapshots include the -optional one-to-one protection-device state, and deletion rejects stale data. -Fixed main-switch and surge-protection header roles are excluded from these -general component commands. +`distribution-board-component.delete`; edits and footer reordering use +`distribution-board-component.update`. Their complete expected/target +snapshots include the optional one-to-one protection-device state and reject +stale data. Updates preserve id, ownership, role and placement. Fixed +main-switch and surge-protection header roles are excluded from these general +component commands. Distribution-board floor assignment and a project-enabled supply type use `distribution-board.update`; both values are snapshot/export fields and one persistent undo step. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 2fd1b07..3141081 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -286,15 +286,18 @@ Freigabe der Netzart in den Projekteinstellungen. Gespeicherte Anlage-Commands der Schemas 1 und 2 bleiben mit ihren vier Legacy-Abschnitten und ohne nachträglich erfundene Komponenten ausführbar. `distribution-board-component.insert` und -`distribution-board-component.delete` versionieren die Anlage und Entfernung -veränderlicher Gruppen-Schutzgeräte und zusätzlicher Verteilergeräte. Der -vollständige Snapshot enthält die stabile Komponenten-UUID und, für -Gruppen-Schutzgeräte, die getrennte 1:1-Schutzgerätekonfiguration. Eigentum an -Projekt, Stromkreisliste und Gruppe sowie der unveränderte Löschzustand werden -innerhalb derselben Transaktion geprüft. Die festen Kopfkomponenten -Hauptschalter und Überspannungsableiter sind von diesen allgemeinen Commands -ausgeschlossen. API- und Editorintegration folgen in den nächsten -Arbeitspaketen. +`distribution-board-component.delete` sowie +`distribution-board-component.update` versionieren Anlage, Entfernung, +Bearbeitung und Sortierung veränderlicher Gruppen-Schutzgeräte und zusätzlicher +Verteilergeräte. Die vollständigen Snapshots enthalten stabile +Komponenten-UUIDs und, für Gruppen-Schutzgeräte, die getrennte +1:1-Schutzgerätekonfiguration. Updates tauschen einen exakt erwarteten gegen +einen vollständigen Zielzustand aus; ID, Eigentum, Rolle und Platzierungszone +bleiben dabei unverändert. Projekt-, Listen- und Gruppenzugehörigkeit sowie +veraltete Zustände werden innerhalb derselben Transaktion geprüft. Die festen +Kopfkomponenten Hauptschalter und Überspannungsableiter sind von diesen +allgemeinen Commands ausgeschlossen. API- und Editorintegration folgen in den +nächsten Arbeitspaketen. `distribution-board.update` versioniert Etage, Netzart und den verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und @@ -365,8 +368,8 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. Ein triggergeführtes Register erzwingt bereits eine normalisierte, stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden - Snapshot-Schema 7. Persistente Insert/Delete-Commands für veränderliche - Verteilerkomponenten sind integriert; weitere Command- und UI-Schritte + Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für + veränderliche Verteilerkomponenten sind integriert; Gruppen- und UI-Schritte folgen in abgegrenzten Arbeitspaketen. PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index 9ff1544..cc12925 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -22,7 +22,7 @@ requirements and intended sequencing, not proof of implementation. components. - [x] Phase C2a1: persistent insert/delete commands for mutable group protection and auxiliary components. -- [ ] Phase C2a2: component update and reorder commands. +- [x] Phase C2a2: component update and reorder commands. - [ ] Phase C2b: group CRUD and reorder commands. - [ ] Phase D: group numbering, moves and destructive operations. - [ ] Phase E: editor projection and editing. diff --git a/docs/spec/09-distribution-board-components-and-protection-groups.md b/docs/spec/09-distribution-board-components-and-protection-groups.md index c43d251..3d908d7 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -593,9 +593,8 @@ Acceptance: ### C. Persistent Commands and Board Defaults -Status: In progress. New-board defaults C1 and component insert/delete commands -C2a1 are complete. Component update/reorder and group management remain -pending. +Status: In progress. New-board defaults C1 and component management C2a are +complete. Group management C2b remains pending. - extend `distribution-board.insert` with fixed components and three groups - implement component and group CRUD commands @@ -626,6 +625,16 @@ Implemented in C2a1: component UUID and a late revision/history failure rolls back the complete write +Implemented in C2a2: + +- `distribution-board-component.update` exchanges one complete expected + component/protection snapshot for one complete target snapshot +- name, BMK, protection configuration and `sortOrder` can change atomically +- component id, list/group ownership, role and placement zone remain immutable + through this general update command +- stale expected state and cross-entity BMK collisions are rejected; Undo/Redo + restores the exact previous snapshot + Acceptance: - new boards contain the agreed fixed structure diff --git a/src/db/repositories/distribution-board-component-structure-project-command.repository.ts b/src/db/repositories/distribution-board-component-structure-project-command.repository.ts index 5f9117c..2dc3bba 100644 --- a/src/db/repositories/distribution-board-component-structure-project-command.repository.ts +++ b/src/db/repositories/distribution-board-component-structure-project-command.repository.ts @@ -2,10 +2,13 @@ import { and, eq } from "drizzle-orm"; import { assertDistributionBoardComponentDeleteProjectCommand, assertDistributionBoardComponentInsertProjectCommand, + assertDistributionBoardComponentUpdateProjectCommand, createDistributionBoardComponentDeleteProjectCommand, createDistributionBoardComponentInsertProjectCommand, + createDistributionBoardComponentUpdateProjectCommand, distributionBoardComponentDeleteCommandType, distributionBoardComponentInsertCommandType, + distributionBoardComponentUpdateCommandType, type DistributionBoardComponentSnapshot, type DistributionBoardComponentStructureProjectCommand, } from "../../domain/models/distribution-board-component-structure-project-command.model.js"; @@ -59,6 +62,19 @@ export class DistributionBoardComponentStructureProjectCommandRepository command.payload.snapshot ); } + if (command.type === distributionBoardComponentUpdateCommandType) { + assertDistributionBoardComponentUpdateProjectCommand(command); + this.update( + database, + projectId, + command.payload.expected, + command.payload.target + ); + return createDistributionBoardComponentUpdateProjectCommand( + command.payload.target, + command.payload.expected + ); + } throw new Error( "Unsupported distribution-board component structure command." ); @@ -139,6 +155,60 @@ export class DistributionBoardComponentStructureProjectCommandRepository } } + private update( + database: AppDatabase, + projectId: string, + expected: DistributionBoardComponentSnapshot, + target: DistributionBoardComponentSnapshot + ) { + this.assertOwnership(database, projectId, expected); + this.assertOwnership(database, projectId, target); + const component = database + .select() + .from(distributionBoardComponents) + .where(eq(distributionBoardComponents.id, expected.component.id)) + .get(); + const protection = database + .select() + .from(distributionBoardComponentProtectionDevices) + .where( + eq( + distributionBoardComponentProtectionDevices.componentId, + expected.component.id + ) + ) + .get(); + if ( + !component || + !sameRecord(expected.component, component) || + !sameNullableRecord(expected.protectionDevice, protection) + ) { + throw new Error( + "Distribution-board component changed before update." + ); + } + database + .update(distributionBoardComponents) + .set(target.component) + .where(eq(distributionBoardComponents.id, expected.component.id)) + .run(); + if ( + expected.protectionDevice !== null && + target.protectionDevice !== null + ) { + database + .update(distributionBoardComponentProtectionDevices) + .set(target.protectionDevice) + .where( + eq( + distributionBoardComponentProtectionDevices.componentId, + expected.component.id + ) + ) + .run(); + } + } + private assertOwnership( database: AppDatabase, projectId: string, diff --git a/src/domain/models/distribution-board-component-structure-project-command.model.ts b/src/domain/models/distribution-board-component-structure-project-command.model.ts index 9f9a187..d540748 100644 --- a/src/domain/models/distribution-board-component-structure-project-command.model.ts +++ b/src/domain/models/distribution-board-component-structure-project-command.model.ts @@ -13,6 +13,8 @@ export const distributionBoardComponentInsertCommandType = "distribution-board-component.insert" as const; export const distributionBoardComponentDeleteCommandType = "distribution-board-component.delete" as const; +export const distributionBoardComponentUpdateCommandType = + "distribution-board-component.update" as const; export const distributionBoardComponentStructureCommandSchemaVersion = 1 as const; @@ -47,6 +49,11 @@ interface DistributionBoardComponentStructurePayload { snapshot: DistributionBoardComponentSnapshot; } +interface DistributionBoardComponentUpdatePayload { + expected: DistributionBoardComponentSnapshot; + target: DistributionBoardComponentSnapshot; +} + export interface DistributionBoardComponentInsertProjectCommand extends SerializedProjectCommand { schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion; @@ -59,9 +66,16 @@ export interface DistributionBoardComponentDeleteProjectCommand type: typeof distributionBoardComponentDeleteCommandType; } +export interface DistributionBoardComponentUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion; + type: typeof distributionBoardComponentUpdateCommandType; +} + export type DistributionBoardComponentStructureProjectCommand = | DistributionBoardComponentInsertProjectCommand - | DistributionBoardComponentDeleteProjectCommand; + | DistributionBoardComponentDeleteProjectCommand + | DistributionBoardComponentUpdateProjectCommand; export function createDistributionBoardComponentInsertProjectCommand( snapshot: DistributionBoardComponentSnapshot @@ -89,6 +103,20 @@ export function createDistributionBoardComponentDeleteProjectCommand( return command; } +export function createDistributionBoardComponentUpdateProjectCommand( + expected: DistributionBoardComponentSnapshot, + target: DistributionBoardComponentSnapshot +): DistributionBoardComponentUpdateProjectCommand { + const command: DistributionBoardComponentUpdateProjectCommand = { + schemaVersion: + distributionBoardComponentStructureCommandSchemaVersion, + type: distributionBoardComponentUpdateCommandType, + payload: { expected, target }, + }; + assertDistributionBoardComponentUpdateProjectCommand(command); + return command; +} + export function assertDistributionBoardComponentInsertProjectCommand( command: SerializedProjectCommand ): asserts command is DistributionBoardComponentInsertProjectCommand { @@ -107,6 +135,28 @@ export function assertDistributionBoardComponentDeleteProjectCommand( ); } +export function assertDistributionBoardComponentUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is DistributionBoardComponentUpdateProjectCommand { + if ( + command.schemaVersion !== + distributionBoardComponentStructureCommandSchemaVersion || + command.type !== distributionBoardComponentUpdateCommandType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 2 + ) { + throw new Error( + "Unsupported distribution-board component update command." + ); + } + assertDistributionBoardComponentSnapshot(command.payload.expected); + assertDistributionBoardComponentSnapshot(command.payload.target); + assertSameComponentIdentity( + command.payload.expected, + command.payload.target + ); +} + function assertStructureCommand( command: SerializedProjectCommand, type: @@ -127,6 +177,25 @@ function assertStructureCommand( assertDistributionBoardComponentSnapshot(command.payload.snapshot); } +function assertSameComponentIdentity( + expected: DistributionBoardComponentSnapshot, + target: DistributionBoardComponentSnapshot +) { + for (const field of [ + "id", + "circuitListId", + "sectionId", + "role", + "placement", + ] as const) { + if (expected.component[field] !== target.component[field]) { + throw new Error( + "Distribution-board component updates cannot change ownership or role." + ); + } + } +} + export function assertDistributionBoardComponentSnapshot( value: unknown ): asserts value is DistributionBoardComponentSnapshot { diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index c8c7195..b5ce810 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -44,8 +44,10 @@ import { import { assertDistributionBoardComponentDeleteProjectCommand, assertDistributionBoardComponentInsertProjectCommand, + assertDistributionBoardComponentUpdateProjectCommand, distributionBoardComponentDeleteCommandType, distributionBoardComponentInsertCommandType, + distributionBoardComponentUpdateCommandType, } from "../models/distribution-board-component-structure-project-command.model.js"; import { assertDistributionBoardDeleteProjectCommand, @@ -322,6 +324,15 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case distributionBoardComponentUpdateCommandType: { + assertDistributionBoardComponentUpdateProjectCommand( + input.command + ); + return this.distributionBoardComponentStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } case projectFloorInsertCommandType: { assertProjectFloorInsertProjectCommand(input.command); return this.projectLocationStructureStore.execute({ diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index 6fce755..ca88f1e 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -35,6 +35,7 @@ const commandTypeLabels: Record = { "distribution-board.delete": "Verteilung entfernt", "distribution-board-component.insert": "Verteilergerät angelegt", "distribution-board-component.delete": "Verteilergerät entfernt", + "distribution-board-component.update": "Verteilergerät bearbeitet", "project-floor.insert": "Geschoss angelegt", "project-floor.delete": "Geschoss entfernt", "project-room.insert": "Raum angelegt", diff --git a/tests/distribution-board-component-structure-project-command.repository.test.ts b/tests/distribution-board-component-structure-project-command.repository.test.ts index 657a783..a4534bb 100644 --- a/tests/distribution-board-component-structure-project-command.repository.test.ts +++ b/tests/distribution-board-component-structure-project-command.repository.test.ts @@ -17,6 +17,7 @@ import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; import { createDistributionBoardComponentInsertProjectCommand, + createDistributionBoardComponentUpdateProjectCommand, type DistributionBoardComponentSnapshot, } from "../src/domain/models/distribution-board-component-structure-project-command.model.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; @@ -198,6 +199,272 @@ describe("distribution-board component structure project command", () => { } }); + it("updates an auxiliary component and restores its name, BMK and order", () => { + const context = createTestDatabase(); + try { + const { list } = getListAndSection(context); + const expected: DistributionBoardComponentSnapshot = { + component: { + id: "component-update-aux", + circuitListId: list.id, + sectionId: null, + equipmentIdentifier: "-K20", + name: "KNX Aktor", + role: "auxiliary", + placement: "footer", + sortOrder: 10, + }, + protectionDevice: null, + }; + const target: DistributionBoardComponentSnapshot = { + component: { + ...expected.component, + equipmentIdentifier: "-K21", + name: "KNX Jalousieaktor", + sortOrder: 30, + }, + protectionDevice: null, + }; + const repository = + new DistributionBoardComponentStructureProjectCommandRepository( + context.db + ); + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createDistributionBoardComponentInsertProjectCommand( + expected + ), + }); + const updated = repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: + createDistributionBoardComponentUpdateProjectCommand( + expected, + target + ), + }); + assert.deepEqual( + context.db + .select() + .from(distributionBoardComponents) + .where(eq(distributionBoardComponents.id, expected.component.id)) + .get(), + target.component + ); + + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "undo", + historyTargetChangeSetId: new ProjectHistoryRepository( + context.db + ).getNextCommand("project-1", "undo")?.changeSetId, + command: updated.inverse, + }); + assert.deepEqual( + context.db + .select() + .from(distributionBoardComponents) + .where(eq(distributionBoardComponents.id, expected.component.id)) + .get(), + expected.component + ); + } finally { + context.close(); + } + }); + + it("updates group protection data atomically and rejects stale state", () => { + const context = createTestDatabase(); + try { + const { list, section } = getListAndSection(context); + const expected: DistributionBoardComponentSnapshot = { + component: { + id: "component-update-protection", + circuitListId: list.id, + sectionId: section.id, + equipmentIdentifier: "-1Q1.0", + name: "Gruppen-FI", + role: "group_residual_current_protection", + placement: "group", + sortOrder: 10, + }, + protectionDevice: { + componentId: "component-update-protection", + type: "FI", + ratedCurrentA: 40, + fuseUtilizationCategory: null, + tripCharacteristic: null, + rcdType: "A", + ratedResidualCurrentMa: 30, + }, + }; + const target: DistributionBoardComponentSnapshot = { + component: { + ...expected.component, + name: "Gruppen-FI 63 A", + }, + protectionDevice: { + ...expected.protectionDevice!, + ratedCurrentA: 63, + }, + }; + const repository = + new DistributionBoardComponentStructureProjectCommandRepository( + context.db + ); + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createDistributionBoardComponentInsertProjectCommand( + expected + ), + }); + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: + createDistributionBoardComponentUpdateProjectCommand( + expected, + target + ), + }); + assert.deepEqual( + context.db + .select() + .from(distributionBoardComponentProtectionDevices) + .where( + eq( + distributionBoardComponentProtectionDevices.componentId, + expected.component.id + ) + ) + .get(), + target.protectionDevice + ); + + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "user", + command: + createDistributionBoardComponentUpdateProjectCommand( + expected, + target + ), + }), + /changed before update/ + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 2 + ); + assert.throws( + () => + createDistributionBoardComponentUpdateProjectCommand( + target, + { + ...target, + component: { + ...target.component, + sectionId: "another-section", + }, + } + ), + /cannot change ownership or role/ + ); + } finally { + context.close(); + } + }); + + it("rolls back a component update when history persistence fails", () => { + const context = createTestDatabase(); + try { + const { list } = getListAndSection(context); + const expected: DistributionBoardComponentSnapshot = { + component: { + id: "component-update-rollback", + circuitListId: list.id, + sectionId: null, + equipmentIdentifier: "-K30", + name: "Aktor", + role: "auxiliary", + placement: "footer", + sortOrder: 10, + }, + protectionDevice: null, + }; + const target: DistributionBoardComponentSnapshot = { + component: { + ...expected.component, + name: "Geänderter Aktor", + sortOrder: 20, + }, + protectionDevice: null, + }; + const repository = + new DistributionBoardComponentStructureProjectCommandRepository( + context.db + ); + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createDistributionBoardComponentInsertProjectCommand( + expected + ), + }); + context.sqlite.exec(` + CREATE TRIGGER fail_component_update_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced component update history failure'); + END; + `); + + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: + createDistributionBoardComponentUpdateProjectCommand( + expected, + target + ), + }), + /forced component update history failure/ + ); + assert.deepEqual( + context.db + .select() + .from(distributionBoardComponents) + .where(eq(distributionBoardComponents.id, expected.component.id)) + .get(), + expected.component + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 1 + ); + } finally { + context.close(); + } + }); + it("rejects fixed roles, foreign sections, duplicate BMKs and stale revisions", () => { const context = createTestDatabase(); try { diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 23a270f..92e8f81 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -51,7 +51,10 @@ import { createDistributionBoardInsertProjectCommand, createDistributionBoardStructureSnapshot, } from "../src/domain/models/distribution-board-structure-project-command.model.js"; -import { createDistributionBoardComponentInsertProjectCommand } from "../src/domain/models/distribution-board-component-structure-project-command.model.js"; +import { + createDistributionBoardComponentInsertProjectCommand, + createDistributionBoardComponentUpdateProjectCommand, +} from "../src/domain/models/distribution-board-component-structure-project-command.model.js"; import { createProjectFloorInsertProjectCommand, createProjectFloorSnapshot, @@ -1121,6 +1124,89 @@ describe("project command service", () => { } }); + it("dispatches distribution-board component updates", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const circuitListId = context.db + .select() + .from(circuitSections) + .get()!.circuitListId; + const expected = { + component: { + id: "component-service-update", + circuitListId, + sectionId: null, + equipmentIdentifier: "-K11", + name: "KNX Aktor", + role: "auxiliary" as const, + placement: "footer" as const, + sortOrder: 10, + }, + protectionDevice: null, + }; + const target = { + component: { + ...expected.component, + name: "KNX Jalousieaktor", + sortOrder: 20, + }, + protectionDevice: null, + }; + service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: + createDistributionBoardComponentInsertProjectCommand( + expected + ), + }); + const updated = service.executeUser({ + projectId: "project-1", + expectedRevision: 1, + command: + createDistributionBoardComponentUpdateProjectCommand( + expected, + target + ), + }); + assert.equal(updated.history.currentRevision, 2); + assert.equal( + context.db + .select() + .from(distributionBoardComponents) + .where( + eq( + distributionBoardComponents.id, + expected.component.id + ) + ) + .get()?.name, + target.component.name + ); + + createService(context).undo({ + projectId: "project-1", + expectedRevision: 2, + }); + assert.equal( + context.db + .select() + .from(distributionBoardComponents) + .where( + eq( + distributionBoardComponents.id, + expected.component.id + ) + ) + .get()?.name, + expected.component.name + ); + } finally { + context.close(); + } + }); + it("dispatches floor and room setup with their persisted inverses", () => { const context = createTestDatabase(); try { diff --git a/tests/project-version-history.test.ts b/tests/project-version-history.test.ts index 9eb1987..b3053de 100644 --- a/tests/project-version-history.test.ts +++ b/tests/project-version-history.test.ts @@ -131,6 +131,14 @@ describe("project version history presentation", () => { ), "Verteilergerät entfernt" ); + assert.equal( + getProjectRevisionDescription( + revision(7, { + commandType: "distribution-board-component.update", + }) + ), + "Verteilergerät bearbeitet" + ); assert.equal(getProjectSnapshotKindLabel("named"), "Benannt"); assert.equal( getProjectSnapshotKindLabel("automatic"),