Add component update commands

This commit is contained in:
2026-07-30 19:42:09 +02:00
parent 604604f056
commit 69b020339e
11 changed files with 547 additions and 21 deletions
+6 -4
View File
@@ -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.
+14 -11
View File
@@ -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
+1 -1
View File
@@ -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.
@@ -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
@@ -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,
@@ -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<DistributionBoardComponentStructurePayload> {
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
@@ -59,9 +66,16 @@ export interface DistributionBoardComponentDeleteProjectCommand
type: typeof distributionBoardComponentDeleteCommandType;
}
export interface DistributionBoardComponentUpdateProjectCommand
extends SerializedProjectCommand<DistributionBoardComponentUpdatePayload> {
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<unknown>
): asserts command is DistributionBoardComponentInsertProjectCommand {
@@ -107,6 +135,28 @@ export function assertDistributionBoardComponentDeleteProjectCommand(
);
}
export function assertDistributionBoardComponentUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): 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<unknown>,
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 {
@@ -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({
@@ -35,6 +35,7 @@ const commandTypeLabels: Record<string, string> = {
"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",
@@ -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 {
+87 -1
View File
@@ -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 {
+8
View File
@@ -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"),