diff --git a/AGENTS.md b/AGENTS.md index 789d050..2141c99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,8 +224,8 @@ Session-local UI undo/redo exists for structural and destructive operations. The server already persists immutable revisions and project-wide undo/redo eligibility for the first commands. Public command, undo and redo endpoints exist for Circuit and CircuitDeviceRow field updates as well as -CircuitDeviceRow insertion/deletion. Complete command coverage and the UI -cutover remain future work. +Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and +the UI cutover remain future work. Required operations: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index fc20959..69cd4fe 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -23,13 +23,15 @@ concurrency checks. - executes the eligible inverse or forward command as a new auditable revision -The public dispatcher currently supports `circuit.update`, -`circuit-device-row.update`, `circuit-device-row.insert` and -`circuit-device-row.delete`, all with schema version `1`. Other command types -are rejected. Insert commands contain the complete row with its stable id; -delete commands identify the row and its expected circuit. This makes deletion -undoable without generating a replacement identity. The editor does not -consume these endpoints yet. +The public dispatcher currently supports `circuit.update`, `circuit.insert`, +`circuit.delete`, `circuit-device-row.update`, +`circuit-device-row.insert` and `circuit-device-row.delete`, all with schema +version `1`. Other command types are rejected. Insert commands contain the +complete entity or circuit block with stable ids; delete commands include the +expected parent identity. Circuit snapshots contain zero, one or multiple +complete device rows. This makes deletion undoable without generating +replacement identities or renumbering circuits. The editor does not consume +these endpoints yet. Example: diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 9288298..20bfb0c 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -82,10 +82,13 @@ die unterstützten Typen `circuit.update` und `circuit-device-row.update` über `circuit-device-row.insert` und `circuit-device-row.delete` als atomare Strukturkommandos vorhanden. Beim Löschen wird die vollständige Zeile im inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte -wiederherstellt. Das Grid verwendet diese Endpunkte noch nicht; seine sichtbare -Historie bleibt daher sitzungslokal. Die übrigen Struktur-, Synchronisierungs- -und Löschoperationen müssen vor der Frontend-Umstellung ebenfalls als -persistente Commands modelliert werden. +wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen +Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block. +Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine +Betriebsmittelkennzeichen. Das Grid verwendet diese Endpunkte noch nicht; seine +sichtbare Historie bleibt daher sitzungslokal. Verschiebe-, Sortier-, +Neunummerierungs- und Synchronisierungsoperationen müssen vor der +Frontend-Umstellung ebenfalls als persistente Commands modelliert werden. ## Projektgeräte diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index c860f6d..e36b532 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -176,12 +176,15 @@ Completed foundation: - `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable row ids, complete deleted-row snapshots and circuit reserve state through atomic insert/delete, revision and stack transitions +- `circuit.insert` and `circuit.delete` persist a complete circuit block with + zero, one or multiple device rows; undo restores all ids and fields without + renumbering Remaining constraints before completing persistent history: - extend the concrete project-scoped command union and executors beyond the - Circuit field and CircuitDeviceRow field/insert/delete slices; the generic - versioned command envelope is complete + Circuit field/insert/delete and CircuitDeviceRow field/insert/delete slices; + the generic versioned command envelope is complete - route the remaining structural, synchronization and destructive domain writes through the shared command, revision and stack transaction boundary - replace the session-local frontend command stack only after server-side diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 985981a..36825d7 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -393,6 +393,8 @@ Implemented foundation: - typed CircuitDeviceRow insert/delete commands preserve complete row snapshots and stable ids; row mutation, circuit reserve state, revision and history stack transition commit or roll back together +- typed Circuit insert/delete commands treat the circuit and all device rows as + one block, preserve every stable id and never renumber neighbouring circuits - revision metadata, change-set payloads and the project counter are committed in one SQLite transaction - a stale expected revision produces no history writes diff --git a/package.json b/package.json index 35ca411..26ba22d 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/repositories/circuit-device-row-structure-project-command.repository.ts b/src/db/repositories/circuit-device-row-structure-project-command.repository.ts index 19f9159..6e1f4de 100644 --- a/src/db/repositories/circuit-device-row-structure-project-command.repository.ts +++ b/src/db/repositories/circuit-device-row-structure-project-command.repository.ts @@ -17,8 +17,10 @@ import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuits } from "../schema/circuits.js"; -import { projectDevices } from "../schema/project-devices.js"; -import { rooms } from "../schema/rooms.js"; +import { + assertCircuitDeviceRowReferencesInProject, + toCircuitDeviceRowSnapshot, +} from "./circuit-device-row-structure.persistence.js"; import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { appendProjectRevision } from "./project-revision.persistence.js"; @@ -93,7 +95,7 @@ export class CircuitDeviceRowStructureProjectCommandRepository ); } this.assertCircuitInProject(database, projectId, row.circuitId); - this.assertReferencesInProject(database, projectId, row); + assertCircuitDeviceRowReferencesInProject(database, projectId, row); const existing = database .select({ id: circuitDeviceRows.id }) @@ -194,69 +196,4 @@ export class CircuitDeviceRowStructureProjectCommandRepository throw new Error("Circuit does not belong to project."); } } - - private assertReferencesInProject( - database: AppDatabase, - projectId: string, - row: CircuitDeviceRowSnapshot - ) { - if (row.linkedProjectDeviceId !== null) { - const device = database - .select({ id: projectDevices.id }) - .from(projectDevices) - .where( - and( - eq(projectDevices.id, row.linkedProjectDeviceId), - eq(projectDevices.projectId, projectId) - ) - ) - .get(); - if (!device) { - throw new Error("Invalid linked project device id."); - } - } - if (row.roomId !== null) { - const room = database - .select({ id: rooms.id }) - .from(rooms) - .where( - and( - eq(rooms.id, row.roomId), - eq(rooms.projectId, projectId) - ) - ) - .get(); - if (!room) { - throw new Error("Invalid room id."); - } - } - } -} - -function toCircuitDeviceRowSnapshot( - row: typeof circuitDeviceRows.$inferSelect -): CircuitDeviceRowSnapshot { - return { - id: row.id, - circuitId: row.circuitId, - linkedProjectDeviceId: row.linkedProjectDeviceId, - legacyConsumerId: row.legacyConsumerId, - sortOrder: row.sortOrder, - name: row.name, - displayName: row.displayName, - phaseType: row.phaseType, - connectionKind: row.connectionKind, - costGroup: row.costGroup, - category: row.category, - level: row.level, - roomId: row.roomId, - roomNumberSnapshot: row.roomNumberSnapshot, - roomNameSnapshot: row.roomNameSnapshot, - quantity: row.quantity, - powerPerUnit: row.powerPerUnit, - simultaneityFactor: row.simultaneityFactor, - cosPhi: row.cosPhi, - remark: row.remark, - overriddenFields: row.overriddenFields, - }; } diff --git a/src/db/repositories/circuit-device-row-structure.persistence.ts b/src/db/repositories/circuit-device-row-structure.persistence.ts new file mode 100644 index 0000000..09aee9f --- /dev/null +++ b/src/db/repositories/circuit-device-row-structure.persistence.ts @@ -0,0 +1,71 @@ +import { and, eq } from "drizzle-orm"; +import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { projectDevices } from "../schema/project-devices.js"; +import { rooms } from "../schema/rooms.js"; + +export function assertCircuitDeviceRowReferencesInProject( + database: AppDatabase, + projectId: string, + row: CircuitDeviceRowSnapshot +) { + if (row.linkedProjectDeviceId !== null) { + const device = database + .select({ id: projectDevices.id }) + .from(projectDevices) + .where( + and( + eq(projectDevices.id, row.linkedProjectDeviceId), + eq(projectDevices.projectId, projectId) + ) + ) + .get(); + if (!device) { + throw new Error("Invalid linked project device id."); + } + } + if (row.roomId !== null) { + const room = database + .select({ id: rooms.id }) + .from(rooms) + .where( + and( + eq(rooms.id, row.roomId), + eq(rooms.projectId, projectId) + ) + ) + .get(); + if (!room) { + throw new Error("Invalid room id."); + } + } +} + +export function toCircuitDeviceRowSnapshot( + row: typeof circuitDeviceRows.$inferSelect +): CircuitDeviceRowSnapshot { + return { + id: row.id, + circuitId: row.circuitId, + linkedProjectDeviceId: row.linkedProjectDeviceId, + legacyConsumerId: row.legacyConsumerId, + sortOrder: row.sortOrder, + name: row.name, + displayName: row.displayName, + phaseType: row.phaseType, + connectionKind: row.connectionKind, + costGroup: row.costGroup, + category: row.category, + level: row.level, + roomId: row.roomId, + roomNumberSnapshot: row.roomNumberSnapshot, + roomNameSnapshot: row.roomNameSnapshot, + quantity: row.quantity, + powerPerUnit: row.powerPerUnit, + simultaneityFactor: row.simultaneityFactor, + cosPhi: row.cosPhi, + remark: row.remark, + overriddenFields: row.overriddenFields, + }; +} diff --git a/src/db/repositories/circuit-structure-project-command.repository.ts b/src/db/repositories/circuit-structure-project-command.repository.ts new file mode 100644 index 0000000..8a41ffa --- /dev/null +++ b/src/db/repositories/circuit-structure-project-command.repository.ts @@ -0,0 +1,291 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import { + assertCircuitDeleteProjectCommand, + assertCircuitInsertProjectCommand, + circuitDeleteCommandType, + circuitInsertCommandType, + createCircuitDeleteProjectCommand, + createCircuitInsertProjectCommand, + type CircuitSnapshot, + type CircuitStructureProjectCommand, +} from "../../domain/models/circuit-structure-project-command.model.js"; +import type { + CircuitStructureProjectCommandStore, + ExecuteCircuitStructureCommandInput, +} from "../../domain/ports/circuit-structure-project-command.store.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 { + assertCircuitDeviceRowReferencesInProject, + toCircuitDeviceRowSnapshot, +} from "./circuit-device-row-structure.persistence.js"; +import { applyProjectHistoryTransition } from "./project-history.persistence.js"; +import { appendProjectRevision } from "./project-revision.persistence.js"; + +export class CircuitStructureProjectCommandRepository + implements CircuitStructureProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteCircuitStructureCommandInput) { + return this.database.transaction((tx) => { + const inverse = this.applyCommand( + tx, + input.projectId, + input.source, + input.command + ); + const revision = appendProjectRevision(tx, { + projectId: input.projectId, + expectedRevision: input.expectedRevision, + source: input.source, + description: input.description, + actorId: input.actorId, + forward: input.command, + inverse, + }); + applyProjectHistoryTransition(tx, { + projectId: input.projectId, + source: input.source, + recordedChangeSetId: revision.changeSetId, + targetChangeSetId: input.historyTargetChangeSetId, + }); + return { revision, inverse }; + }); + } + + private applyCommand( + database: AppDatabase, + projectId: string, + source: ExecuteCircuitStructureCommandInput["source"], + command: CircuitStructureProjectCommand + ): CircuitStructureProjectCommand { + if (command.type === circuitInsertCommandType) { + assertCircuitInsertProjectCommand(command); + return this.insert( + database, + projectId, + source, + command.payload.circuit + ); + } + if (command.type === circuitDeleteCommandType) { + assertCircuitDeleteProjectCommand(command); + return this.delete( + database, + projectId, + command.payload.circuitId, + command.payload.expectedCircuitListId + ); + } + throw new Error("Unsupported circuit structure command."); + } + + private insert( + database: AppDatabase, + projectId: string, + source: ExecuteCircuitStructureCommandInput["source"], + snapshot: CircuitSnapshot + ) { + this.assertCircuitLocation(database, projectId, snapshot); + + const existingCircuit = database + .select({ id: circuits.id }) + .from(circuits) + .where(eq(circuits.id, snapshot.id)) + .get(); + if (existingCircuit) { + throw new Error("Circuit id already exists."); + } + const duplicateIdentifier = database + .select({ id: circuits.id }) + .from(circuits) + .where( + and( + eq(circuits.circuitListId, snapshot.circuitListId), + eq( + circuits.equipmentIdentifier, + snapshot.equipmentIdentifier + ) + ) + ) + .get(); + if (duplicateIdentifier) { + throw new Error( + "Duplicate equipmentIdentifier in circuit list." + ); + } + + if (snapshot.deviceRows.length > 0) { + const rowIds = snapshot.deviceRows.map((row) => row.id); + const existingRow = database + .select({ id: circuitDeviceRows.id }) + .from(circuitDeviceRows) + .where(inArray(circuitDeviceRows.id, rowIds)) + .limit(1) + .get(); + if (existingRow) { + throw new Error("Circuit device-row id already exists."); + } + for (const row of snapshot.deviceRows) { + if (source === "user" && row.legacyConsumerId !== null) { + throw new Error( + "User commands cannot assign legacy consumer ids." + ); + } + assertCircuitDeviceRowReferencesInProject( + database, + projectId, + row + ); + } + } + + database + .insert(circuits) + .values({ + id: snapshot.id, + circuitListId: snapshot.circuitListId, + sectionId: snapshot.sectionId, + equipmentIdentifier: snapshot.equipmentIdentifier, + displayName: snapshot.displayName, + sortOrder: snapshot.sortOrder, + protectionType: snapshot.protectionType, + protectionRatedCurrent: snapshot.protectionRatedCurrent, + protectionCharacteristic: snapshot.protectionCharacteristic, + cableType: snapshot.cableType, + cableCrossSection: snapshot.cableCrossSection, + cableLength: snapshot.cableLength, + rcdAssignment: snapshot.rcdAssignment, + terminalDesignation: snapshot.terminalDesignation, + voltage: snapshot.voltage, + controlRequirement: snapshot.controlRequirement, + status: snapshot.status, + isReserve: snapshot.isReserve ? 1 : 0, + remark: snapshot.remark, + }) + .run(); + if (snapshot.deviceRows.length > 0) { + database.insert(circuitDeviceRows).values(snapshot.deviceRows).run(); + } + + return createCircuitDeleteProjectCommand( + snapshot.id, + snapshot.circuitListId + ); + } + + private delete( + database: AppDatabase, + projectId: string, + circuitId: string, + expectedCircuitListId: string + ) { + const circuit = database + .select() + .from(circuits) + .where(eq(circuits.id, circuitId)) + .get(); + if (!circuit || circuit.circuitListId !== expectedCircuitListId) { + throw new Error("Circuit changed before command execution."); + } + const owningList = database + .select({ id: circuitLists.id }) + .from(circuitLists) + .where( + and( + eq(circuitLists.id, circuit.circuitListId), + eq(circuitLists.projectId, projectId) + ) + ) + .get(); + if (!owningList) { + throw new Error("Circuit does not belong to project."); + } + + const rows = database + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, circuit.id)) + .orderBy( + asc(circuitDeviceRows.sortOrder), + asc(circuitDeviceRows.id) + ) + .all(); + const inverse = createCircuitInsertProjectCommand({ + id: circuit.id, + circuitListId: circuit.circuitListId, + sectionId: circuit.sectionId, + equipmentIdentifier: circuit.equipmentIdentifier, + displayName: circuit.displayName, + sortOrder: circuit.sortOrder, + protectionType: circuit.protectionType, + protectionRatedCurrent: circuit.protectionRatedCurrent, + protectionCharacteristic: circuit.protectionCharacteristic, + cableType: circuit.cableType, + cableCrossSection: circuit.cableCrossSection, + cableLength: circuit.cableLength, + rcdAssignment: circuit.rcdAssignment, + terminalDesignation: circuit.terminalDesignation, + voltage: circuit.voltage, + controlRequirement: circuit.controlRequirement, + status: circuit.status, + isReserve: Boolean(circuit.isReserve), + remark: circuit.remark, + deviceRows: rows.map(toCircuitDeviceRowSnapshot), + }); + + const result = database + .delete(circuits) + .where( + and( + eq(circuits.id, circuitId), + eq(circuits.circuitListId, expectedCircuitListId) + ) + ) + .run(); + if (result.changes !== 1) { + throw new Error("Circuit could not be deleted."); + } + return inverse; + } + + private assertCircuitLocation( + database: AppDatabase, + projectId: string, + snapshot: CircuitSnapshot + ) { + const list = database + .select({ id: circuitLists.id }) + .from(circuitLists) + .where( + and( + eq(circuitLists.id, snapshot.circuitListId), + eq(circuitLists.projectId, projectId) + ) + ) + .get(); + if (!list) { + throw new Error("Circuit list does not belong to project."); + } + const section = database + .select({ id: circuitSections.id }) + .from(circuitSections) + .where( + and( + eq(circuitSections.id, snapshot.sectionId), + eq( + circuitSections.circuitListId, + snapshot.circuitListId + ) + ) + ) + .get(); + if (!section) { + throw new Error("Section does not belong to circuit list."); + } + } +} diff --git a/src/domain/models/circuit-structure-project-command.model.ts b/src/domain/models/circuit-structure-project-command.model.ts new file mode 100644 index 0000000..42e1803 --- /dev/null +++ b/src/domain/models/circuit-structure-project-command.model.ts @@ -0,0 +1,220 @@ +import { + assertCircuitDeviceRowInsertProjectCommand, + circuitDeviceRowInsertCommandType, + circuitDeviceRowStructureCommandSchemaVersion, + type CircuitDeviceRowSnapshot, +} from "./circuit-device-row-structure-project-command.model.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const circuitInsertCommandType = "circuit.insert" as const; +export const circuitDeleteCommandType = "circuit.delete" as const; +export const circuitStructureCommandSchemaVersion = 1 as const; + +export interface CircuitSnapshot { + id: string; + circuitListId: string; + sectionId: string; + equipmentIdentifier: string; + displayName: string | null; + sortOrder: number; + protectionType: string | null; + protectionRatedCurrent: number | null; + protectionCharacteristic: string | null; + cableType: string | null; + cableCrossSection: string | null; + cableLength: number | null; + rcdAssignment: string | null; + terminalDesignation: string | null; + voltage: number | null; + controlRequirement: string | null; + status: string | null; + isReserve: boolean; + remark: string | null; + deviceRows: CircuitDeviceRowSnapshot[]; +} + +export interface CircuitInsertCommandPayload { + circuit: CircuitSnapshot; +} + +export interface CircuitDeleteCommandPayload { + circuitId: string; + expectedCircuitListId: string; +} + +export interface CircuitInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitStructureCommandSchemaVersion; + type: typeof circuitInsertCommandType; +} + +export interface CircuitDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitStructureCommandSchemaVersion; + type: typeof circuitDeleteCommandType; +} + +export type CircuitStructureProjectCommand = + | CircuitInsertProjectCommand + | CircuitDeleteProjectCommand; + +export function createCircuitInsertProjectCommand( + circuit: CircuitSnapshot +): CircuitInsertProjectCommand { + const command: CircuitInsertProjectCommand = { + schemaVersion: circuitStructureCommandSchemaVersion, + type: circuitInsertCommandType, + payload: { circuit }, + }; + assertCircuitInsertProjectCommand(command); + return command; +} + +export function createCircuitDeleteProjectCommand( + circuitId: string, + expectedCircuitListId: string +): CircuitDeleteProjectCommand { + const command: CircuitDeleteProjectCommand = { + schemaVersion: circuitStructureCommandSchemaVersion, + type: circuitDeleteCommandType, + payload: { circuitId, expectedCircuitListId }, + }; + assertCircuitDeleteProjectCommand(command); + return command; +} + +export function assertCircuitInsertProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitInsertProjectCommand { + if ( + command.schemaVersion !== circuitStructureCommandSchemaVersion || + command.type !== circuitInsertCommandType + ) { + throw new Error("Unsupported circuit insert command."); + } + if (!isPlainObject(command.payload)) { + throw new Error("Circuit insert payload must be an object."); + } + const circuit = command.payload.circuit; + if (!isPlainObject(circuit)) { + throw new Error("Circuit insert command requires a circuit."); + } + + assertNonEmptyString(circuit.id, "circuit.id"); + assertNonEmptyString(circuit.circuitListId, "circuit.circuitListId"); + assertNonEmptyString(circuit.sectionId, "circuit.sectionId"); + assertNonEmptyString( + circuit.equipmentIdentifier, + "circuit.equipmentIdentifier" + ); + assertNullableString(circuit.displayName, "circuit.displayName"); + assertFiniteNumber(circuit.sortOrder, "circuit.sortOrder"); + for (const field of [ + "protectionType", + "protectionCharacteristic", + "cableType", + "cableCrossSection", + "rcdAssignment", + "terminalDesignation", + "controlRequirement", + "status", + "remark", + ] as const) { + assertNullableString(circuit[field], `circuit.${field}`); + } + assertNullableNonNegativeNumber( + circuit.protectionRatedCurrent, + "circuit.protectionRatedCurrent" + ); + assertNullableNonNegativeNumber( + circuit.cableLength, + "circuit.cableLength" + ); + if (circuit.voltage !== null) { + assertFiniteNumber(circuit.voltage, "circuit.voltage"); + if (circuit.voltage <= 0) { + throw new Error("circuit.voltage must be positive or null."); + } + } + if (typeof circuit.isReserve !== "boolean") { + throw new Error("circuit.isReserve must be a boolean."); + } + if (!Array.isArray(circuit.deviceRows)) { + throw new Error("circuit.deviceRows must be an array."); + } + if (circuit.isReserve !== (circuit.deviceRows.length === 0)) { + throw new Error( + "Circuit reserve state must match whether device rows exist." + ); + } + + const rowIds = new Set(); + for (const row of circuit.deviceRows) { + assertCircuitDeviceRowInsertProjectCommand({ + schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, + type: circuitDeviceRowInsertCommandType, + payload: { row }, + }); + if (row.circuitId !== circuit.id) { + throw new Error("Circuit device row belongs to a different circuit."); + } + if (rowIds.has(row.id)) { + throw new Error("Circuit insert command contains duplicate row ids."); + } + rowIds.add(row.id); + } +} + +export function assertCircuitDeleteProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitDeleteProjectCommand { + if ( + command.schemaVersion !== circuitStructureCommandSchemaVersion || + command.type !== circuitDeleteCommandType + ) { + throw new Error("Unsupported circuit delete command."); + } + if (!isPlainObject(command.payload)) { + throw new Error("Circuit delete payload must be an object."); + } + assertNonEmptyString(command.payload.circuitId, "circuitId"); + assertNonEmptyString( + command.payload.expectedCircuitListId, + "expectedCircuitListId" + ); +} + +function assertNonEmptyString(value: unknown, field: string) { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${field} must be a non-empty string.`); + } +} + +function assertNullableString(value: unknown, field: string) { + if (value !== null && typeof value !== "string") { + throw new Error(`${field} must be a string or null.`); + } +} + +function assertFiniteNumber( + value: unknown, + field: string +): asserts value is number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${field} must be a finite number.`); + } +} + +function assertNullableNonNegativeNumber(value: unknown, field: string) { + if (value === null) { + return; + } + assertFiniteNumber(value, field); + if (value < 0) { + throw new Error(`${field} must not be negative.`); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/circuit-structure-project-command.store.ts b/src/domain/ports/circuit-structure-project-command.store.ts new file mode 100644 index 0000000..04a5941 --- /dev/null +++ b/src/domain/ports/circuit-structure-project-command.store.ts @@ -0,0 +1,26 @@ +import type { CircuitStructureProjectCommand } from "../models/circuit-structure-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteCircuitStructureCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: CircuitStructureProjectCommand; +} + +export interface ExecutedCircuitStructureCommand { + revision: AppendedProjectRevision; + inverse: CircuitStructureProjectCommand; +} + +export interface CircuitStructureProjectCommandStore { + execute( + input: ExecuteCircuitStructureCommandInput + ): ExecutedCircuitStructureCommand; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 0d8d7c3..eda00ce 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -13,6 +13,12 @@ import { assertCircuitUpdateProjectCommand, circuitUpdateCommandType, } from "../models/circuit-project-command.model.js"; +import { + assertCircuitDeleteProjectCommand, + assertCircuitInsertProjectCommand, + circuitDeleteCommandType, + circuitInsertCommandType, +} from "../models/circuit-structure-project-command.model.js"; import { assertSerializedProjectCommand, type SerializedProjectCommand, @@ -20,6 +26,7 @@ import { import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js"; import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js"; import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js"; +import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js"; import type { ExecuteProjectHistoryCommandInput, ExecutedProjectCommand, @@ -47,6 +54,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly circuitStore: CircuitProjectCommandStore, private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore, private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore, + private readonly circuitStructureStore: CircuitStructureProjectCommandStore, private readonly historyStore: ProjectHistoryStore ) {} @@ -140,6 +148,20 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitInsertCommandType: { + assertCircuitInsertProjectCommand(input.command); + return this.circuitStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } + case circuitDeleteCommandType: { + assertCircuitDeleteProjectCommand(input.command); + return this.circuitStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } default: throw new Error( `Unsupported project command type: ${input.command.type}.` diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index d81f5c4..a580927 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -2,6 +2,7 @@ import { db } from "../../db/client.js"; import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js"; import { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js"; +import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js"; import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js"; @@ -10,10 +11,13 @@ export const circuitDeviceRowProjectCommandStore = new CircuitDeviceRowProjectCommandRepository(db); export const circuitDeviceRowStructureProjectCommandStore = new CircuitDeviceRowStructureProjectCommandRepository(db); +export const circuitStructureProjectCommandStore = + new CircuitStructureProjectCommandRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db); export const projectCommandService = new ProjectCommandService( circuitProjectCommandStore, circuitDeviceRowProjectCommandStore, circuitDeviceRowStructureProjectCommandStore, + circuitStructureProjectCommandStore, projectHistoryStore ); diff --git a/tests/circuit-structure-project-command.repository.test.ts b/tests/circuit-structure-project-command.repository.test.ts new file mode 100644 index 0000000..9c35c3b --- /dev/null +++ b/tests/circuit-structure-project-command.repository.test.ts @@ -0,0 +1,565 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { asc, eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; +import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { projectDevices } from "../src/db/schema/project-devices.js"; +import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projects } from "../src/db/schema/projects.js"; +import { rooms } from "../src/db/schema/rooms.js"; +import type { CircuitDeviceRowSnapshot } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; +import { + createCircuitDeleteProjectCommand, + createCircuitInsertProjectCommand, + type CircuitSnapshot, +} from "../src/domain/models/circuit-structure-project-command.model.js"; + +interface TestFixture { + context: DatabaseContext; + circuitListId: string; + sectionId: string; + foreignCircuitListId: string; + foreignSectionId: string; +} + +function createTestDatabase(): TestFixture { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db + .insert(projects) + .values([ + { id: "project-1", name: "Test project" }, + { id: "project-2", name: "Other project" }, + ]) + .run(); + + const boards = new DistributionBoardRepository(context.db); + const board = boards.createWithCircuitListAndDefaultSections( + "project-1", + "UV-01" + ); + const foreignBoard = boards.createWithCircuitListAndDefaultSections( + "project-2", + "UV-02" + ); + const section = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, board.id)) + .get(); + const foreignSection = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, foreignBoard.id)) + .get(); + assert.ok(section); + assert.ok(foreignSection); + + context.db + .insert(projectDevices) + .values([ + { + id: "device-1", + projectId: "project-1", + name: "Leuchte", + displayName: "Leuchte", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }, + { + id: "device-foreign", + projectId: "project-2", + name: "Fremdgerät", + displayName: "Fremdgerät", + quantity: 1, + powerPerUnit: 0.2, + simultaneityFactor: 1, + }, + ]) + .run(); + context.db + .insert(rooms) + .values([ + { + id: "room-1", + projectId: "project-1", + roomNumber: "001", + roomName: "Büro", + }, + { + id: "room-foreign", + projectId: "project-2", + roomNumber: "999", + roomName: "Fremdraum", + }, + ]) + .run(); + context.db + .insert(circuits) + .values({ + id: "circuit-existing", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F1", + displayName: "Bestand", + sortOrder: 10, + protectionType: "LS", + protectionRatedCurrent: 16, + protectionCharacteristic: "B", + cableType: "NYM-J", + cableCrossSection: "3x1,5", + cableLength: 18.5, + rcdAssignment: "FI-1", + terminalDesignation: "X1", + voltage: 230, + controlRequirement: "DALI", + status: "planned", + isReserve: 0, + remark: "Vollständig", + }) + .run(); + context.db + .insert(circuitDeviceRows) + .values([ + { + id: "row-existing-1", + circuitId: "circuit-existing", + linkedProjectDeviceId: "device-1", + legacyConsumerId: "legacy-1", + sortOrder: 10, + name: "Leuchte A", + displayName: "Leuchte A lokal", + phaseType: "single_phase", + category: "lighting", + roomId: "room-1", + roomNumberSnapshot: "001", + roomNameSnapshot: "Büro", + quantity: 2, + powerPerUnit: 0.1, + simultaneityFactor: 0.8, + cosPhi: 0.9, + overriddenFields: "[\"displayName\"]", + }, + { + id: "row-existing-2", + circuitId: "circuit-existing", + sortOrder: 20, + name: "Leuchte B", + displayName: "Leuchte B", + quantity: 1, + powerPerUnit: 0.2, + simultaneityFactor: 1, + }, + ]) + .run(); + + return { + context, + circuitListId: board.id, + sectionId: section.id, + foreignCircuitListId: foreignBoard.id, + foreignSectionId: foreignSection.id, + }; +} + +function createRow( + circuitId: string, + id: string, + sortOrder: number, + overrides: Partial = {} +): CircuitDeviceRowSnapshot { + return { + id, + circuitId, + linkedProjectDeviceId: null, + legacyConsumerId: null, + sortOrder, + name: `Gerät ${id}`, + displayName: `Gerät ${id}`, + phaseType: "single_phase", + connectionKind: null, + costGroup: null, + category: null, + level: null, + roomId: null, + roomNumberSnapshot: null, + roomNameSnapshot: null, + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + cosPhi: null, + remark: null, + overriddenFields: null, + ...overrides, + }; +} + +function createCircuitSnapshot( + fixture: TestFixture, + overrides: Partial = {} +): CircuitSnapshot { + const id = overrides.id ?? "circuit-new"; + const deviceRows = + overrides.deviceRows ?? + [ + createRow(id, "row-new-1", 10, { + linkedProjectDeviceId: "device-1", + roomId: "room-1", + }), + createRow(id, "row-new-2", 20), + ]; + return { + id, + circuitListId: fixture.circuitListId, + sectionId: fixture.sectionId, + equipmentIdentifier: "-1F2", + displayName: "Neuer Stromkreis", + sortOrder: 20, + protectionType: null, + protectionRatedCurrent: null, + protectionCharacteristic: null, + cableType: null, + cableCrossSection: null, + cableLength: null, + rcdAssignment: null, + terminalDesignation: null, + voltage: 230, + controlRequirement: null, + status: null, + isReserve: deviceRows.length === 0, + remark: null, + deviceRows, + ...overrides, + }; +} + +function getCircuit( + context: DatabaseContext, + circuitId: string +) { + return context.db + .select() + .from(circuits) + .where(eq(circuits.id, circuitId)) + .get(); +} + +function getRows(context: DatabaseContext, circuitId: string) { + return context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, circuitId)) + .orderBy( + asc(circuitDeviceRows.sortOrder), + asc(circuitDeviceRows.id) + ) + .all(); +} + +describe("circuit structure project-command repository", () => { + it("inserts a multi-device circuit and supports persisted undo and redo", () => { + const fixture = createTestDatabase(); + try { + const store = new CircuitStructureProjectCommandRepository( + fixture.context.db + ); + const command = createCircuitInsertProjectCommand( + createCircuitSnapshot(fixture) + ); + const inserted = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.equal(getCircuit(fixture.context, "circuit-new")?.isReserve, 0); + assert.deepEqual( + getRows(fixture.context, "circuit-new").map((row) => row.id), + ["row-new-1", "row-new-2"] + ); + + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command: inserted.inverse, + }); + assert.equal(getCircuit(fixture.context, "circuit-new"), undefined); + assert.equal(getRows(fixture.context, "circuit-new").length, 0); + + store.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command, + }); + assert.deepEqual( + getRows(fixture.context, "circuit-new").map((row) => row.id), + ["row-new-1", "row-new-2"] + ); + assert.deepEqual( + new ProjectHistoryRepository(fixture.context.db).getState( + "project-1" + ), + { + projectId: "project-1", + currentRevision: 3, + undoDepth: 1, + redoDepth: 0, + undoChangeSetId: inserted.revision.changeSetId, + redoChangeSetId: null, + } + ); + } finally { + fixture.context.close(); + } + }); + + it("inserts and removes an empty reserve circuit", () => { + const fixture = createTestDatabase(); + try { + const store = new CircuitStructureProjectCommandRepository( + fixture.context.db + ); + const inserted = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitInsertProjectCommand( + createCircuitSnapshot(fixture, { + id: "circuit-reserve", + equipmentIdentifier: "-1F3", + deviceRows: [], + isReserve: true, + }) + ), + }); + assert.equal( + getCircuit(fixture.context, "circuit-reserve")?.isReserve, + 1 + ); + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command: inserted.inverse, + }); + assert.equal( + getCircuit(fixture.context, "circuit-reserve"), + undefined + ); + } finally { + fixture.context.close(); + } + }); + + it("deletes and restores a complete multi-device circuit block", () => { + const fixture = createTestDatabase(); + try { + const beforeCircuit = getCircuit( + fixture.context, + "circuit-existing" + ); + const beforeRows = getRows(fixture.context, "circuit-existing"); + assert.ok(beforeCircuit); + const store = new CircuitStructureProjectCommandRepository( + fixture.context.db + ); + const deleted = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeleteProjectCommand( + "circuit-existing", + fixture.circuitListId + ), + }); + assert.equal( + getCircuit(fixture.context, "circuit-existing"), + undefined + ); + assert.equal(getRows(fixture.context, "circuit-existing").length, 0); + + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: deleted.revision.changeSetId, + command: deleted.inverse, + }); + assert.deepEqual( + getCircuit(fixture.context, "circuit-existing"), + beforeCircuit + ); + assert.deepEqual( + getRows(fixture.context, "circuit-existing"), + beforeRows + ); + } finally { + fixture.context.close(); + } + }); + + it("rolls back insert and delete for late history failures", () => { + const fixture = createTestDatabase(); + try { + fixture.context.sqlite.exec(` + CREATE TRIGGER fail_circuit_structure_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced circuit structure history failure'); + END; + `); + const store = new CircuitStructureProjectCommandRepository( + fixture.context.db + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitInsertProjectCommand( + createCircuitSnapshot(fixture) + ), + }), + /forced circuit structure history failure/ + ); + assert.equal(getCircuit(fixture.context, "circuit-new"), undefined); + + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeleteProjectCommand( + "circuit-existing", + fixture.circuitListId + ), + }), + /forced circuit structure history failure/ + ); + assert.ok(getCircuit(fixture.context, "circuit-existing")); + assert.equal(getRows(fixture.context, "circuit-existing").length, 2); + assert.equal( + fixture.context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + fixture.context.close(); + } + }); + + it("rolls back deletion for a stale expected revision", () => { + const fixture = createTestDatabase(); + try { + const store = new CircuitStructureProjectCommandRepository( + fixture.context.db + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createCircuitDeleteProjectCommand( + "circuit-existing", + fixture.circuitListId + ), + }), + /at revision 0, expected 1/ + ); + assert.ok(getCircuit(fixture.context, "circuit-existing")); + assert.equal(getRows(fixture.context, "circuit-existing").length, 2); + } finally { + fixture.context.close(); + } + }); + + it("rejects invalid ownership, identifiers, references and row ids", () => { + const fixture = createTestDatabase(); + try { + const store = new CircuitStructureProjectCommandRepository( + fixture.context.db + ); + const invalidSnapshots = [ + createCircuitSnapshot(fixture, { + id: "foreign-list", + circuitListId: fixture.foreignCircuitListId, + sectionId: fixture.foreignSectionId, + }), + createCircuitSnapshot(fixture, { + id: "foreign-section", + sectionId: fixture.foreignSectionId, + }), + createCircuitSnapshot(fixture, { + id: "duplicate-bmk", + equipmentIdentifier: "-1F1", + }), + createCircuitSnapshot(fixture, { + id: "duplicate-row", + deviceRows: [ + createRow("duplicate-row", "row-existing-1", 10), + ], + }), + createCircuitSnapshot(fixture, { + id: "foreign-device", + deviceRows: [ + createRow("foreign-device", "row-foreign-device", 10, { + linkedProjectDeviceId: "device-foreign", + }), + ], + }), + createCircuitSnapshot(fixture, { + id: "foreign-room", + deviceRows: [ + createRow("foreign-room", "row-foreign-room", 10, { + roomId: "room-foreign", + }), + ], + }), + createCircuitSnapshot(fixture, { + id: "legacy-row", + deviceRows: [ + createRow("legacy-row", "row-legacy", 10, { + legacyConsumerId: "legacy-user-value", + }), + ], + }), + ]; + for (const snapshot of invalidSnapshots) { + assert.throws(() => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitInsertProjectCommand(snapshot), + }) + ); + } + assert.equal( + fixture.context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + fixture.context.close(); + } + }); +}); diff --git a/tests/project-command.model.test.ts b/tests/project-command.model.test.ts index 7992e16..8a25142 100644 --- a/tests/project-command.model.test.ts +++ b/tests/project-command.model.test.ts @@ -18,6 +18,11 @@ import { createCircuitDeviceRowDeleteProjectCommand, createCircuitDeviceRowInsertProjectCommand, } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; +import { + assertCircuitInsertProjectCommand, + createCircuitDeleteProjectCommand, + createCircuitInsertProjectCommand, +} from "../src/domain/models/circuit-structure-project-command.model.js"; describe("serialized project commands", () => { it("round-trips a versioned command envelope", () => { @@ -240,3 +245,102 @@ describe("circuit device-row structure project commands", () => { ); }); }); + +describe("circuit structure project commands", () => { + const row = { + id: "row-1", + circuitId: "circuit-1", + linkedProjectDeviceId: null, + legacyConsumerId: null, + sortOrder: 10, + name: "Leuchte", + displayName: "Leuchte", + phaseType: "single_phase", + connectionKind: null, + costGroup: null, + category: "lighting", + level: null, + roomId: null, + roomNumberSnapshot: null, + roomNameSnapshot: null, + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + cosPhi: null, + remark: null, + overriddenFields: null, + }; + const circuit = { + id: "circuit-1", + circuitListId: "list-1", + sectionId: "section-1", + equipmentIdentifier: "-1F1", + displayName: "Beleuchtung", + sortOrder: 10, + protectionType: null, + protectionRatedCurrent: null, + protectionCharacteristic: null, + cableType: null, + cableCrossSection: null, + cableLength: null, + rcdAssignment: null, + terminalDesignation: null, + voltage: 230, + controlRequirement: "DALI", + status: null, + isReserve: false, + remark: null, + deviceRows: [row], + }; + + it("captures complete circuit blocks and delete identities", () => { + const insert = createCircuitInsertProjectCommand(circuit); + const remove = createCircuitDeleteProjectCommand( + circuit.id, + circuit.circuitListId + ); + assert.deepEqual(insert.payload.circuit, circuit); + assert.deepEqual(remove.payload, { + circuitId: "circuit-1", + expectedCircuitListId: "list-1", + }); + }); + + it("rejects inconsistent reserve state, row ownership and duplicate ids", () => { + assert.throws( + () => + createCircuitInsertProjectCommand({ + ...circuit, + isReserve: true, + }), + /reserve state/ + ); + assert.throws( + () => + createCircuitInsertProjectCommand({ + ...circuit, + deviceRows: [{ ...row, circuitId: "other-circuit" }], + }), + /different circuit/ + ); + assert.throws( + () => + createCircuitInsertProjectCommand({ + ...circuit, + deviceRows: [row, { ...row }], + }), + /duplicate row ids/ + ); + assert.throws( + () => + assertCircuitInsertProjectCommand({ + schemaVersion: 1, + type: "circuit.insert", + payload: { + circuit: { ...circuit, equipmentIdentifier: "" }, + }, + }), + /equipmentIdentifier/ + ); + }); +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 239dcad..d24502a 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -10,6 +10,7 @@ import { import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js"; import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js"; +import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; @@ -23,6 +24,7 @@ import { ProjectRevisionConflictError } from "../src/domain/errors/project-revis import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; +import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; function createTestDatabase(): DatabaseContext { @@ -75,6 +77,7 @@ function createService(context: DatabaseContext) { new CircuitProjectCommandRepository(context.db), new CircuitDeviceRowProjectCommandRepository(context.db), new CircuitDeviceRowStructureProjectCommandRepository(context.db), + new CircuitStructureProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } @@ -255,6 +258,70 @@ describe("project command service", () => { } }); + it("dispatches complete circuit insertion and its persisted inverse", () => { + const context = createTestDatabase(); + try { + const existingCircuit = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .get(); + assert.ok(existingCircuit); + const service = createService(context); + const inserted = service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createCircuitInsertProjectCommand({ + id: "circuit-new", + circuitListId: existingCircuit.circuitListId, + sectionId: existingCircuit.sectionId, + equipmentIdentifier: "-1F2", + displayName: "Reserve", + sortOrder: 20, + protectionType: null, + protectionRatedCurrent: null, + protectionCharacteristic: null, + cableType: null, + cableCrossSection: null, + cableLength: null, + rcdAssignment: null, + terminalDesignation: null, + voltage: 230, + controlRequirement: null, + status: null, + isReserve: true, + remark: null, + deviceRows: [], + }), + }); + assert.equal(inserted.history.undoDepth, 1); + assert.equal( + context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-new")) + .get()?.equipmentIdentifier, + "-1F2" + ); + + const undone = createService(context).undo({ + projectId: "project-1", + expectedRevision: 1, + }); + assert.equal(undone.history.redoDepth, 1); + assert.equal( + context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-new")) + .get(), + undefined + ); + } finally { + context.close(); + } + }); + it("rejects unavailable history directions without writing a revision", () => { const context = createTestDatabase(); try {