From 53282e8c7c8aaa795536de118c887cecf92f2364 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 23 Jul 2026 22:39:59 +0200 Subject: [PATCH] Add placeholder move history --- AGENTS.md | 5 +- docs/circuit-list-editor-api.md | 26 +- docs/current-architecture.md | 10 +- ...history-and-external-model-architecture.md | 8 +- docs/spec/07-implementation-phases-todo.md | 3 + ...ice-row-move-project-command.repository.ts | 609 ++++++++++++++---- ...t-device-row-move-project-command.model.ts | 116 ++++ ...t-device-row-move-project-command.store.ts | 6 +- .../services/project-command.service.ts | 11 + ...ow-move-project-command.repository.test.ts | 276 +++++++- tests/project-command.model.test.ts | 106 +++ tests/project-command.service.test.ts | 90 ++- 12 files changed, 1106 insertions(+), 160 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 641c6a9..1ad06b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,8 +226,9 @@ eligibility for the first commands. Public command, undo and redo endpoints exist for Circuit and CircuitDeviceRow field updates as well as Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and the UI cutover remain future work. CircuitDeviceRow moves between existing -circuits are also persisted; moves that create a target circuit remain future -work. +circuits and moves that create one new placeholder target circuit are also +persisted. The latter stores the complete empty target snapshot so undo can +restore the rows and remove only the unchanged generated circuit. Required operations: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 1a1a9e5..9066816 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -26,18 +26,22 @@ concurrency checks. The public dispatcher currently supports `circuit.update`, `circuit.insert`, `circuit.delete`, `circuit-device-row.update`, `circuit-device-row.insert`, `circuit-device-row.delete` and -`circuit-device-row.move`, 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. Move commands -contain each row's expected and target circuit plus its exact expected and -target sort order. This makes deletion and moves undoable without generating -replacement identities or renumbering circuits. The editor does not consume -these endpoints yet. +`circuit-device-row.move` plus +`circuit-device-row.move-with-new-circuit`, 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. Move commands contain each row's expected and target circuit plus its +exact expected and target sort order. This makes deletion and moves undoable +without generating replacement identities or renumbering circuits. The editor +does not consume these endpoints yet. -`circuit-device-row.move` currently targets existing circuits in the same -circuit list. Creating a new target circuit while moving rows is not yet part -of the persistent command API. +`circuit-device-row.move` targets existing circuits in the same circuit list. +`circuit-device-row.move-with-new-circuit` atomically creates exactly one +explicitly identified empty target circuit and moves one or multiple existing +rows into it. Its inverse restores every row to its exact prior position and +deletes the generated circuit only if its fields and complete row set still +match the recorded state. Example: diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 8acb809..b5a8b29 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -91,9 +91,13 @@ sichtbare Historie bleibt daher sitzungslokal. zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; Reservewerte aller beteiligten Stromkreise werden atomar neu abgeleitet. Das -Verschieben auf einen freien Platz mit gleichzeitiger Stromkreiserzeugung sowie -Circuit-Sortierung, Neunummerierung und Synchronisierung müssen vor der -Frontend-Umstellung noch als persistente Commands modelliert werden. +Kompositkommando `circuit-device-row.move-with-new-circuit` bildet auch das +Verschieben auf einen freien Platz ab: Ein Zielstromkreis mit stabiler UUID und +BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten +Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen +Felder und vollständiger Zeilenbestand unverändert sind. Circuit-Sortierung, +Neunummerierung und Synchronisierung müssen vor der Frontend-Umstellung noch +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 c3de1fd..11a18ca 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -182,13 +182,17 @@ Completed foundation: - `circuit-device-row.move` stores exact expected and target circuit/sort positions for one or multiple rows, recalculates all affected reserve states and reconstructs a deterministic inverse across multiple source circuits +- `circuit-device-row.move-with-new-circuit` additionally records the complete + empty target-circuit snapshot; forward creation and all row moves share one + transaction, while undo restores the source positions and deletes only an + unchanged generated target Remaining constraints before completing persistent history: - extend the concrete project-scoped command union and executors beyond the Circuit field/insert/delete and CircuitDeviceRow - field/insert/delete/existing-target-move slices; the generic versioned - command envelope is complete + field/insert/delete/move 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 250e0b6..52bc9d4 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -398,6 +398,9 @@ Implemented foundation: - typed CircuitDeviceRow move commands persist exact old/new circuit and sort positions for single or multi-row moves between existing circuits and derive affected reserve states in the same transaction +- placeholder-target moves persist one complete generated circuit identity and + all row positions atomically; undo restores every source and removes only an + unchanged generated circuit, without renumbering - 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/src/db/repositories/circuit-device-row-move-project-command.repository.ts b/src/db/repositories/circuit-device-row-move-project-command.repository.ts index 92eb8e2..b6de191 100644 --- a/src/db/repositories/circuit-device-row-move-project-command.repository.ts +++ b/src/db/repositories/circuit-device-row-move-project-command.repository.ts @@ -1,8 +1,15 @@ import { and, eq, inArray } from "drizzle-orm"; import { assertCircuitDeviceRowMoveProjectCommand, + assertCircuitDeviceRowMoveWithNewCircuitProjectCommand, + circuitDeviceRowMoveCommandType, + circuitDeviceRowMoveWithNewCircuitCommandType, createCircuitDeviceRowMoveProjectCommand, + createCircuitDeviceRowMoveWithNewCircuitProjectCommand, + type CircuitDeviceRowMoveAssignment, + type CircuitDeviceRowMoveHistoryProjectCommand, } from "../../domain/models/circuit-device-row-move-project-command.model.js"; +import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js"; import type { CircuitDeviceRowMoveProjectCommandStore, ExecuteCircuitDeviceRowMoveCommandInput, @@ -10,156 +17,31 @@ import type { 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 { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { appendProjectRevision } from "./project-revision.persistence.js"; +interface PersistedMoveRow { + id: string; + circuitId: string; + sortOrder: number; +} + export class CircuitDeviceRowMoveProjectCommandRepository implements CircuitDeviceRowMoveProjectCommandStore { constructor(private readonly database: AppDatabase) {} execute(input: ExecuteCircuitDeviceRowMoveCommandInput) { - assertCircuitDeviceRowMoveProjectCommand(input.command); + this.assertSupportedCommand(input.command); return this.database.transaction((tx) => { - const rowIds = input.command.payload.moves.map( - (move) => move.rowId + const inverse = this.applyCommand( + tx, + input.projectId, + input.command ); - const persistedRows = tx - .select({ - id: circuitDeviceRows.id, - circuitId: circuitDeviceRows.circuitId, - sortOrder: circuitDeviceRows.sortOrder, - }) - .from(circuitDeviceRows) - .where(inArray(circuitDeviceRows.id, rowIds)) - .all(); - if (persistedRows.length !== rowIds.length) { - throw new Error( - "One or more circuit device rows do not exist." - ); - } - const rowsById = new Map( - persistedRows.map((row) => [row.id, row]) - ); - for (const move of input.command.payload.moves) { - const row = rowsById.get(move.rowId); - if ( - !row || - row.circuitId !== move.expectedCircuitId || - row.sortOrder !== move.expectedSortOrder - ) { - throw new Error( - "Circuit device row changed before move execution." - ); - } - } - - const circuitIds = [ - ...new Set( - input.command.payload.moves.flatMap((move) => [ - move.expectedCircuitId, - move.targetCircuitId, - ]) - ), - ]; - const participatingCircuits = tx - .select({ - id: circuits.id, - circuitListId: circuits.circuitListId, - projectId: circuitLists.projectId, - }) - .from(circuits) - .innerJoin( - circuitLists, - eq(circuitLists.id, circuits.circuitListId) - ) - .where(inArray(circuits.id, circuitIds)) - .all(); - if (participatingCircuits.length !== circuitIds.length) { - throw new Error( - "One or more move circuits do not exist." - ); - } - if ( - participatingCircuits.some( - (circuit) => circuit.projectId !== input.projectId - ) - ) { - throw new Error("Move circuit does not belong to project."); - } - const circuitListIds = new Set( - participatingCircuits.map( - (circuit) => circuit.circuitListId - ) - ); - if (circuitListIds.size !== 1) { - throw new Error( - "All moved rows and targets must belong to one circuit list." - ); - } - - const inverse = createCircuitDeviceRowMoveProjectCommand( - input.command.payload.moves.map((move) => { - const current = rowsById.get(move.rowId)!; - return { - rowId: move.rowId, - expectedCircuitId: move.targetCircuitId, - expectedSortOrder: move.targetSortOrder, - targetCircuitId: current.circuitId, - targetSortOrder: current.sortOrder, - }; - }) - ); - - for (const move of input.command.payload.moves) { - const result = tx - .update(circuitDeviceRows) - .set({ - circuitId: move.targetCircuitId, - sortOrder: move.targetSortOrder, - }) - .where( - and( - eq(circuitDeviceRows.id, move.rowId), - eq( - circuitDeviceRows.circuitId, - move.expectedCircuitId - ), - eq( - circuitDeviceRows.sortOrder, - move.expectedSortOrder - ) - ) - ) - .run(); - if (result.changes !== 1) { - throw new Error( - "Circuit device row changed during move execution." - ); - } - } - - for (const circuitId of circuitIds) { - const remainingRow = tx - .select({ id: circuitDeviceRows.id }) - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.circuitId, circuitId)) - .limit(1) - .get(); - const updated = tx - .update(circuits) - .set({ isReserve: remainingRow ? 0 : 1 }) - .where(eq(circuits.id, circuitId)) - .run(); - if (updated.changes !== 1) { - throw new Error( - "Circuit changed during device-row move execution." - ); - } - } - const revision = appendProjectRevision(tx, { projectId: input.projectId, expectedRevision: input.expectedRevision, @@ -178,4 +60,457 @@ export class CircuitDeviceRowMoveProjectCommandRepository return { revision, inverse }; }); } + + private assertSupportedCommand( + command: CircuitDeviceRowMoveHistoryProjectCommand + ) { + if (command.type === circuitDeviceRowMoveCommandType) { + assertCircuitDeviceRowMoveProjectCommand(command); + return; + } + if ( + command.type === + circuitDeviceRowMoveWithNewCircuitCommandType + ) { + assertCircuitDeviceRowMoveWithNewCircuitProjectCommand( + command + ); + return; + } + throw new Error("Unsupported circuit device-row move command."); + } + + private applyCommand( + database: AppDatabase, + projectId: string, + command: CircuitDeviceRowMoveHistoryProjectCommand + ): CircuitDeviceRowMoveHistoryProjectCommand { + if (command.type === circuitDeviceRowMoveCommandType) { + const rowsById = this.loadExpectedRows( + database, + command.payload.moves + ); + const circuitIds = this.collectCircuitIds( + command.payload.moves + ); + this.assertExistingCircuitsInOneList( + database, + projectId, + circuitIds + ); + const inverse = createCircuitDeviceRowMoveProjectCommand( + this.reverseMoves(command.payload.moves, rowsById) + ); + this.applyMoves(database, command.payload.moves); + this.updateReserveStates(database, circuitIds); + return inverse; + } + + assertCircuitDeviceRowMoveWithNewCircuitProjectCommand( + command + ); + const { targetCircuit, targetCircuitAction, moves } = + command.payload; + const rowsById = this.loadExpectedRows(database, moves); + this.assertCircuitLocation(database, projectId, targetCircuit); + + if (targetCircuitAction === "create") { + const sourceCircuitIds = [ + ...new Set(moves.map((move) => move.expectedCircuitId)), + ]; + this.assertExistingCircuitsInOneList( + database, + projectId, + sourceCircuitIds, + targetCircuit.circuitListId + ); + this.assertTargetCircuitAvailable(database, targetCircuit); + this.insertTargetCircuit(database, targetCircuit); + + const inverse = + createCircuitDeviceRowMoveWithNewCircuitProjectCommand( + "delete", + targetCircuit, + this.reverseMoves(moves, rowsById) + ); + this.applyMoves(database, moves); + this.updateReserveStates(database, [ + ...sourceCircuitIds, + targetCircuit.id, + ]); + return inverse; + } + + this.assertTargetCircuitUnchanged( + database, + targetCircuit, + moves.map((move) => move.rowId) + ); + const destinationCircuitIds = [ + ...new Set(moves.map((move) => move.targetCircuitId)), + ]; + this.assertExistingCircuitsInOneList( + database, + projectId, + destinationCircuitIds, + targetCircuit.circuitListId + ); + const inverse = + createCircuitDeviceRowMoveWithNewCircuitProjectCommand( + "create", + targetCircuit, + this.reverseMoves(moves, rowsById) + ); + this.applyMoves(database, moves); + this.updateReserveStates(database, [ + targetCircuit.id, + ...destinationCircuitIds, + ]); + const deleted = database + .delete(circuits) + .where( + and( + eq(circuits.id, targetCircuit.id), + eq( + circuits.circuitListId, + targetCircuit.circuitListId + ), + eq(circuits.isReserve, 1) + ) + ) + .run(); + if (deleted.changes !== 1) { + throw new Error( + "Created target circuit changed before deletion." + ); + } + return inverse; + } + + private loadExpectedRows( + database: AppDatabase, + moves: CircuitDeviceRowMoveAssignment[] + ) { + const rowIds = moves.map((move) => move.rowId); + const persistedRows = database + .select({ + id: circuitDeviceRows.id, + circuitId: circuitDeviceRows.circuitId, + sortOrder: circuitDeviceRows.sortOrder, + }) + .from(circuitDeviceRows) + .where(inArray(circuitDeviceRows.id, rowIds)) + .all(); + if (persistedRows.length !== rowIds.length) { + throw new Error( + "One or more circuit device rows do not exist." + ); + } + + const rowsById = new Map( + persistedRows.map((row) => [row.id, row]) + ); + for (const move of moves) { + const row = rowsById.get(move.rowId); + if ( + !row || + row.circuitId !== move.expectedCircuitId || + row.sortOrder !== move.expectedSortOrder + ) { + throw new Error( + "Circuit device row changed before move execution." + ); + } + } + return rowsById; + } + + private collectCircuitIds( + moves: CircuitDeviceRowMoveAssignment[] + ) { + return [ + ...new Set( + moves.flatMap((move) => [ + move.expectedCircuitId, + move.targetCircuitId, + ]) + ), + ]; + } + + private assertExistingCircuitsInOneList( + database: AppDatabase, + projectId: string, + circuitIds: string[], + expectedCircuitListId?: string + ) { + const participatingCircuits = database + .select({ + id: circuits.id, + circuitListId: circuits.circuitListId, + projectId: circuitLists.projectId, + }) + .from(circuits) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuits.circuitListId) + ) + .where(inArray(circuits.id, circuitIds)) + .all(); + if (participatingCircuits.length !== circuitIds.length) { + throw new Error( + "One or more move circuits do not exist." + ); + } + if ( + participatingCircuits.some( + (circuit) => circuit.projectId !== projectId + ) + ) { + throw new Error("Move circuit does not belong to project."); + } + const circuitListIds = new Set( + participatingCircuits.map( + (circuit) => circuit.circuitListId + ) + ); + if ( + circuitListIds.size !== 1 || + (expectedCircuitListId !== undefined && + !circuitListIds.has(expectedCircuitListId)) + ) { + throw new Error( + "All moved rows and targets must belong to one circuit list." + ); + } + } + + 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."); + } + } + + private assertTargetCircuitAvailable( + database: AppDatabase, + snapshot: CircuitSnapshot + ) { + const existingCircuit = database + .select({ id: circuits.id }) + .from(circuits) + .where(eq(circuits.id, snapshot.id)) + .get(); + if (existingCircuit) { + throw new Error("Move target 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." + ); + } + } + + private insertTargetCircuit( + database: AppDatabase, + snapshot: CircuitSnapshot + ) { + 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: 1, + remark: snapshot.remark, + }) + .run(); + } + + private assertTargetCircuitUnchanged( + database: AppDatabase, + snapshot: CircuitSnapshot, + expectedRowIds: string[] + ) { + const current = database + .select() + .from(circuits) + .where(eq(circuits.id, snapshot.id)) + .get(); + if ( + !current || + current.circuitListId !== snapshot.circuitListId || + current.sectionId !== snapshot.sectionId || + current.equipmentIdentifier !== + snapshot.equipmentIdentifier || + current.displayName !== snapshot.displayName || + current.sortOrder !== snapshot.sortOrder || + current.protectionType !== snapshot.protectionType || + current.protectionRatedCurrent !== + snapshot.protectionRatedCurrent || + current.protectionCharacteristic !== + snapshot.protectionCharacteristic || + current.cableType !== snapshot.cableType || + current.cableCrossSection !== snapshot.cableCrossSection || + current.cableLength !== snapshot.cableLength || + current.rcdAssignment !== snapshot.rcdAssignment || + current.terminalDesignation !== + snapshot.terminalDesignation || + current.voltage !== snapshot.voltage || + current.controlRequirement !== + snapshot.controlRequirement || + current.status !== snapshot.status || + current.remark !== snapshot.remark || + Boolean(current.isReserve) + ) { + throw new Error( + "Created target circuit changed before command execution." + ); + } + + const currentRowIds = database + .select({ id: circuitDeviceRows.id }) + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, snapshot.id)) + .all() + .map((row) => row.id); + const expectedRowIdSet = new Set(expectedRowIds); + if ( + currentRowIds.length !== expectedRowIds.length || + currentRowIds.some((id) => !expectedRowIdSet.has(id)) + ) { + throw new Error( + "Created target circuit rows changed before command execution." + ); + } + } + + private reverseMoves( + moves: CircuitDeviceRowMoveAssignment[], + rowsById: Map + ) { + return moves.map((move) => { + const current = rowsById.get(move.rowId)!; + return { + rowId: move.rowId, + expectedCircuitId: move.targetCircuitId, + expectedSortOrder: move.targetSortOrder, + targetCircuitId: current.circuitId, + targetSortOrder: current.sortOrder, + }; + }); + } + + private applyMoves( + database: AppDatabase, + moves: CircuitDeviceRowMoveAssignment[] + ) { + for (const move of moves) { + const result = database + .update(circuitDeviceRows) + .set({ + circuitId: move.targetCircuitId, + sortOrder: move.targetSortOrder, + }) + .where( + and( + eq(circuitDeviceRows.id, move.rowId), + eq( + circuitDeviceRows.circuitId, + move.expectedCircuitId + ), + eq( + circuitDeviceRows.sortOrder, + move.expectedSortOrder + ) + ) + ) + .run(); + if (result.changes !== 1) { + throw new Error( + "Circuit device row changed during move execution." + ); + } + } + } + + private updateReserveStates( + database: AppDatabase, + circuitIds: string[] + ) { + for (const circuitId of new Set(circuitIds)) { + const remainingRow = database + .select({ id: circuitDeviceRows.id }) + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, circuitId)) + .limit(1) + .get(); + const updated = database + .update(circuits) + .set({ isReserve: remainingRow ? 0 : 1 }) + .where(eq(circuits.id, circuitId)) + .run(); + if (updated.changes !== 1) { + throw new Error( + "Circuit changed during device-row move execution." + ); + } + } + } } diff --git a/src/domain/models/circuit-device-row-move-project-command.model.ts b/src/domain/models/circuit-device-row-move-project-command.model.ts index 82d75e4..f40001e 100644 --- a/src/domain/models/circuit-device-row-move-project-command.model.ts +++ b/src/domain/models/circuit-device-row-move-project-command.model.ts @@ -1,7 +1,15 @@ import type { SerializedProjectCommand } from "./project-command.model.js"; +import { + assertCircuitInsertProjectCommand, + circuitInsertCommandType, + circuitStructureCommandSchemaVersion, + type CircuitSnapshot, +} from "./circuit-structure-project-command.model.js"; export const circuitDeviceRowMoveCommandType = "circuit-device-row.move" as const; +export const circuitDeviceRowMoveWithNewCircuitCommandType = + "circuit-device-row.move-with-new-circuit" as const; export const circuitDeviceRowMoveCommandSchemaVersion = 1 as const; export interface CircuitDeviceRowMoveAssignment { @@ -22,6 +30,26 @@ export interface CircuitDeviceRowMoveProjectCommand type: typeof circuitDeviceRowMoveCommandType; } +export type CircuitDeviceRowMoveTargetCircuitAction = + | "create" + | "delete"; + +export interface CircuitDeviceRowMoveWithNewCircuitCommandPayload { + targetCircuitAction: CircuitDeviceRowMoveTargetCircuitAction; + targetCircuit: CircuitSnapshot; + moves: CircuitDeviceRowMoveAssignment[]; +} + +export interface CircuitDeviceRowMoveWithNewCircuitProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitDeviceRowMoveCommandSchemaVersion; + type: typeof circuitDeviceRowMoveWithNewCircuitCommandType; +} + +export type CircuitDeviceRowMoveHistoryProjectCommand = + | CircuitDeviceRowMoveProjectCommand + | CircuitDeviceRowMoveWithNewCircuitProjectCommand; + export function createCircuitDeviceRowMoveProjectCommand( moves: CircuitDeviceRowMoveAssignment[] ): CircuitDeviceRowMoveProjectCommand { @@ -34,6 +62,24 @@ export function createCircuitDeviceRowMoveProjectCommand( return command; } +export function createCircuitDeviceRowMoveWithNewCircuitProjectCommand( + targetCircuitAction: CircuitDeviceRowMoveTargetCircuitAction, + targetCircuit: CircuitSnapshot, + moves: CircuitDeviceRowMoveAssignment[] +): CircuitDeviceRowMoveWithNewCircuitProjectCommand { + const command: CircuitDeviceRowMoveWithNewCircuitProjectCommand = { + schemaVersion: circuitDeviceRowMoveCommandSchemaVersion, + type: circuitDeviceRowMoveWithNewCircuitCommandType, + payload: { + targetCircuitAction, + targetCircuit, + moves, + }, + }; + assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(command); + return command; +} + export function assertCircuitDeviceRowMoveProjectCommand( command: SerializedProjectCommand ): asserts command is CircuitDeviceRowMoveProjectCommand { @@ -91,6 +137,76 @@ export function assertCircuitDeviceRowMoveProjectCommand( } } +export function assertCircuitDeviceRowMoveWithNewCircuitProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitDeviceRowMoveWithNewCircuitProjectCommand { + if ( + command.schemaVersion !== circuitDeviceRowMoveCommandSchemaVersion || + command.type !== circuitDeviceRowMoveWithNewCircuitCommandType + ) { + throw new Error( + "Unsupported circuit device-row move-with-new-circuit command." + ); + } + if (!isPlainObject(command.payload)) { + throw new Error( + "Circuit device-row move-with-new-circuit payload must be an object." + ); + } + const { targetCircuitAction, targetCircuit, moves } = + command.payload; + if ( + targetCircuitAction !== "create" && + targetCircuitAction !== "delete" + ) { + throw new Error( + "Target circuit action must be create or delete." + ); + } + + assertCircuitInsertProjectCommand({ + schemaVersion: circuitStructureCommandSchemaVersion, + type: circuitInsertCommandType, + payload: { circuit: targetCircuit }, + }); + const validatedTargetCircuit = targetCircuit as CircuitSnapshot; + if ( + validatedTargetCircuit.isReserve !== true || + validatedTargetCircuit.deviceRows.length !== 0 + ) { + throw new Error( + "Move target circuit snapshot must describe an empty reserve circuit." + ); + } + + assertCircuitDeviceRowMoveProjectCommand({ + schemaVersion: circuitDeviceRowMoveCommandSchemaVersion, + type: circuitDeviceRowMoveCommandType, + payload: { moves }, + }); + const validatedMoves = moves as CircuitDeviceRowMoveAssignment[]; + for (const move of validatedMoves) { + if ( + targetCircuitAction === "create" && + (move.expectedCircuitId === validatedTargetCircuit.id || + move.targetCircuitId !== validatedTargetCircuit.id) + ) { + throw new Error( + "Create-target moves must move rows into the new circuit." + ); + } + if ( + targetCircuitAction === "delete" && + (move.expectedCircuitId !== validatedTargetCircuit.id || + move.targetCircuitId === validatedTargetCircuit.id) + ) { + throw new Error( + "Delete-target moves must move rows out of the created circuit." + ); + } + } +} + function assertNonEmptyString( value: unknown, field: string diff --git a/src/domain/ports/circuit-device-row-move-project-command.store.ts b/src/domain/ports/circuit-device-row-move-project-command.store.ts index 0cc17b6..fe41fed 100644 --- a/src/domain/ports/circuit-device-row-move-project-command.store.ts +++ b/src/domain/ports/circuit-device-row-move-project-command.store.ts @@ -1,4 +1,4 @@ -import type { CircuitDeviceRowMoveProjectCommand } from "../models/circuit-device-row-move-project-command.model.js"; +import type { CircuitDeviceRowMoveHistoryProjectCommand } from "../models/circuit-device-row-move-project-command.model.js"; import type { AppendedProjectRevision, ProjectRevisionSource, @@ -11,12 +11,12 @@ export interface ExecuteCircuitDeviceRowMoveCommandInput { description?: string; actorId?: string; historyTargetChangeSetId?: string; - command: CircuitDeviceRowMoveProjectCommand; + command: CircuitDeviceRowMoveHistoryProjectCommand; } export interface ExecutedCircuitDeviceRowMoveCommand { revision: AppendedProjectRevision; - inverse: CircuitDeviceRowMoveProjectCommand; + inverse: CircuitDeviceRowMoveHistoryProjectCommand; } export interface CircuitDeviceRowMoveProjectCommandStore { diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index caf4180..596c12a 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -1,7 +1,9 @@ import { ProjectHistoryOperationUnavailableError } from "../errors/project-history-operation-unavailable.error.js"; import { assertCircuitDeviceRowMoveProjectCommand, + assertCircuitDeviceRowMoveWithNewCircuitProjectCommand, circuitDeviceRowMoveCommandType, + circuitDeviceRowMoveWithNewCircuitCommandType, } from "../models/circuit-device-row-move-project-command.model.js"; import { assertCircuitDeviceRowUpdateProjectCommand, @@ -161,6 +163,15 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitDeviceRowMoveWithNewCircuitCommandType: { + assertCircuitDeviceRowMoveWithNewCircuitProjectCommand( + input.command + ); + return this.deviceRowMoveStore.execute({ + ...input, + command: input.command, + }).revision; + } case circuitInsertCommandType: { assertCircuitInsertProjectCommand(input.command); return this.circuitStructureStore.execute({ diff --git a/tests/circuit-device-row-move-project-command.repository.test.ts b/tests/circuit-device-row-move-project-command.repository.test.ts index 65eadef..2e25558 100644 --- a/tests/circuit-device-row-move-project-command.repository.test.ts +++ b/tests/circuit-device-row-move-project-command.repository.test.ts @@ -15,7 +15,11 @@ import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; -import { createCircuitDeviceRowMoveProjectCommand } from "../src/domain/models/circuit-device-row-move-project-command.model.js"; +import { + createCircuitDeviceRowMoveProjectCommand, + createCircuitDeviceRowMoveWithNewCircuitProjectCommand, +} from "../src/domain/models/circuit-device-row-move-project-command.model.js"; +import type { CircuitSnapshot } from "../src/domain/models/circuit-structure-project-command.model.js"; interface TestFixture { context: DatabaseContext; @@ -164,6 +168,35 @@ function isReserve(context: DatabaseContext, circuitId: string) { ); } +function createMoveTargetSnapshot( + fixture: TestFixture, + overrides: Partial = {} +): CircuitSnapshot { + return { + id: "circuit-new", + circuitListId: fixture.circuitListId, + sectionId: fixture.sectionId, + equipmentIdentifier: "-1F4", + displayName: "Neuer Stromkreis", + sortOrder: 40, + protectionType: null, + protectionRatedCurrent: null, + protectionCharacteristic: null, + cableType: null, + cableCrossSection: null, + cableLength: null, + rcdAssignment: null, + terminalDesignation: null, + voltage: null, + controlRequirement: null, + status: null, + isReserve: true, + remark: null, + deviceRows: [], + ...overrides, + }; +} + describe("circuit device-row move project-command repository", () => { it("moves multiple rows and supports persisted undo and redo", () => { const fixture = createTestDatabase(); @@ -432,4 +465,245 @@ describe("circuit device-row move project-command repository", () => { fixture.context.close(); } }); + + it("creates one target circuit and supports persisted undo and redo", () => { + const fixture = createTestDatabase(); + try { + const store = new CircuitDeviceRowMoveProjectCommandRepository( + fixture.context.db + ); + const targetCircuit = createMoveTargetSnapshot(fixture); + const command = + createCircuitDeviceRowMoveWithNewCircuitProjectCommand( + "create", + targetCircuit, + [ + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 10, + targetCircuitId: targetCircuit.id, + targetSortOrder: 10, + }, + { + rowId: "row-2", + expectedCircuitId: "circuit-2", + expectedSortOrder: 10, + targetCircuitId: targetCircuit.id, + targetSortOrder: 20, + }, + ] + ); + const moved = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + + assert.deepEqual( + [ + getRow(fixture.context, "row-1"), + getRow(fixture.context, "row-2"), + ].map((row) => [row?.circuitId, row?.sortOrder]), + [ + ["circuit-new", 10], + ["circuit-new", 20], + ] + ); + assert.equal( + fixture.context.db + .select({ equipmentIdentifier: circuits.equipmentIdentifier }) + .from(circuits) + .where(eq(circuits.id, "circuit-new")) + .get()?.equipmentIdentifier, + "-1F4" + ); + assert.equal(isReserve(fixture.context, "circuit-new"), false); + assert.equal(isReserve(fixture.context, "circuit-1"), false); + assert.equal(isReserve(fixture.context, "circuit-2"), true); + + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: moved.revision.changeSetId, + command: moved.inverse, + }); + assert.equal( + fixture.context.db + .select({ id: circuits.id }) + .from(circuits) + .where(eq(circuits.id, "circuit-new")) + .get(), + undefined + ); + assert.deepEqual( + [ + getRow(fixture.context, "row-1"), + getRow(fixture.context, "row-2"), + ].map((row) => [row?.circuitId, row?.sortOrder]), + [ + ["circuit-1", 10], + ["circuit-2", 10], + ] + ); + assert.equal(isReserve(fixture.context, "circuit-1"), false); + assert.equal(isReserve(fixture.context, "circuit-2"), false); + + store.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: moved.revision.changeSetId, + command, + }); + assert.equal( + getRow(fixture.context, "row-2")?.circuitId, + "circuit-new" + ); + assert.deepEqual( + new ProjectHistoryRepository(fixture.context.db).getState( + "project-1" + ), + { + projectId: "project-1", + currentRevision: 3, + undoDepth: 1, + redoDepth: 0, + undoChangeSetId: moved.revision.changeSetId, + redoChangeSetId: null, + } + ); + } finally { + fixture.context.close(); + } + }); + + it("rejects deletion when the created target changed outside history", () => { + const fixture = createTestDatabase(); + try { + const store = new CircuitDeviceRowMoveProjectCommandRepository( + fixture.context.db + ); + const targetCircuit = createMoveTargetSnapshot(fixture); + const moved = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createCircuitDeviceRowMoveWithNewCircuitProjectCommand( + "create", + targetCircuit, + [ + { + rowId: "row-2", + expectedCircuitId: "circuit-2", + expectedSortOrder: 10, + targetCircuitId: targetCircuit.id, + targetSortOrder: 10, + }, + ] + ), + }); + fixture.context.db + .update(circuits) + .set({ displayName: "Außerhalb geändert" }) + .where(eq(circuits.id, targetCircuit.id)) + .run(); + + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: + moved.revision.changeSetId, + command: moved.inverse, + }), + /target circuit changed/ + ); + assert.equal( + getRow(fixture.context, "row-2")?.circuitId, + targetCircuit.id + ); + assert.ok( + fixture.context.db + .select({ id: circuits.id }) + .from(circuits) + .where(eq(circuits.id, targetCircuit.id)) + .get() + ); + assert.equal( + new ProjectHistoryRepository(fixture.context.db).getState( + "project-1" + )?.currentRevision, + 1 + ); + } finally { + fixture.context.close(); + } + }); + + it("rolls back target creation and moves for late history failures", () => { + const fixture = createTestDatabase(); + try { + fixture.context.sqlite.exec(` + CREATE TRIGGER fail_composite_move_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced composite move history failure'); + END; + `); + const targetCircuit = createMoveTargetSnapshot(fixture); + const store = new CircuitDeviceRowMoveProjectCommandRepository( + fixture.context.db + ); + + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createCircuitDeviceRowMoveWithNewCircuitProjectCommand( + "create", + targetCircuit, + [ + { + rowId: "row-2", + expectedCircuitId: "circuit-2", + expectedSortOrder: 10, + targetCircuitId: targetCircuit.id, + targetSortOrder: 10, + }, + ] + ), + }), + /forced composite move history failure/ + ); + assert.equal( + fixture.context.db + .select({ id: circuits.id }) + .from(circuits) + .where(eq(circuits.id, targetCircuit.id)) + .get(), + undefined + ); + assert.equal( + getRow(fixture.context, "row-2")?.circuitId, + "circuit-2" + ); + assert.equal(isReserve(fixture.context, "circuit-2"), false); + 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 239ec47..7a37c63 100644 --- a/tests/project-command.model.test.ts +++ b/tests/project-command.model.test.ts @@ -20,7 +20,9 @@ import { } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; import { assertCircuitDeviceRowMoveProjectCommand, + assertCircuitDeviceRowMoveWithNewCircuitProjectCommand, createCircuitDeviceRowMoveProjectCommand, + createCircuitDeviceRowMoveWithNewCircuitProjectCommand, } from "../src/domain/models/circuit-device-row-move-project-command.model.js"; import { assertCircuitInsertProjectCommand, @@ -251,6 +253,29 @@ describe("circuit device-row structure project commands", () => { }); describe("circuit device-row move project commands", () => { + const targetCircuit = { + id: "circuit-new", + circuitListId: "list-1", + sectionId: "section-1", + equipmentIdentifier: "-1F3", + displayName: "Neuer Stromkreis", + sortOrder: 30, + protectionType: null, + protectionRatedCurrent: null, + protectionCharacteristic: null, + cableType: null, + cableCrossSection: null, + cableLength: null, + rcdAssignment: null, + terminalDesignation: null, + voltage: null, + controlRequirement: null, + status: null, + isReserve: true, + remark: null, + deviceRows: [], + }; + it("captures deterministic source and target positions", () => { const command = createCircuitDeviceRowMoveProjectCommand([ { @@ -313,6 +338,87 @@ describe("circuit device-row move project commands", () => { /no-op/ ); }); + + it("captures creation and deletion of a deterministic move target", () => { + const create = + createCircuitDeviceRowMoveWithNewCircuitProjectCommand( + "create", + targetCircuit, + [ + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 10, + targetCircuitId: targetCircuit.id, + targetSortOrder: 10, + }, + ] + ); + const remove = + createCircuitDeviceRowMoveWithNewCircuitProjectCommand( + "delete", + targetCircuit, + [ + { + rowId: "row-1", + expectedCircuitId: targetCircuit.id, + expectedSortOrder: 10, + targetCircuitId: "circuit-1", + targetSortOrder: 10, + }, + ] + ); + assert.doesNotThrow(() => + assertCircuitDeviceRowMoveWithNewCircuitProjectCommand( + create + ) + ); + assert.equal(remove.payload.targetCircuitAction, "delete"); + }); + + it("rejects inconsistent move targets and non-empty snapshots", () => { + assert.throws( + () => + createCircuitDeviceRowMoveWithNewCircuitProjectCommand( + "create", + targetCircuit, + [ + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 10, + targetCircuitId: "other-circuit", + targetSortOrder: 10, + }, + ] + ), + /move rows into the new circuit/ + ); + assert.throws( + () => + assertCircuitDeviceRowMoveWithNewCircuitProjectCommand({ + schemaVersion: 1, + type: "circuit-device-row.move-with-new-circuit", + payload: { + targetCircuitAction: "create", + targetCircuit: { + ...targetCircuit, + isReserve: false, + }, + moves: [ + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 10, + targetCircuitId: targetCircuit.id, + targetSortOrder: 10, + }, + ], + }, + }), + /reserve state/ + ); + }); }); describe("circuit structure project commands", () => { diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 9c42bf7..f22c9f0 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -23,7 +23,10 @@ import { projects } from "../src/db/schema/projects.js"; import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; -import { createCircuitDeviceRowMoveProjectCommand } from "../src/domain/models/circuit-device-row-move-project-command.model.js"; +import { + createCircuitDeviceRowMoveProjectCommand, + createCircuitDeviceRowMoveWithNewCircuitProjectCommand, +} from "../src/domain/models/circuit-device-row-move-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"; @@ -387,6 +390,91 @@ describe("project command service", () => { } }); + it("dispatches placeholder moves that create and remove a circuit", () => { + const context = createTestDatabase(); + try { + const sourceCircuit = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .get(); + assert.ok(sourceCircuit); + const targetCircuit = { + id: "circuit-placeholder-target", + circuitListId: sourceCircuit.circuitListId, + sectionId: sourceCircuit.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: null, + controlRequirement: null, + status: null, + isReserve: true, + remark: null, + deviceRows: [], + }; + + const moved = createService(context).executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: + createCircuitDeviceRowMoveWithNewCircuitProjectCommand( + "create", + targetCircuit, + [ + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 10, + targetCircuitId: targetCircuit.id, + targetSortOrder: 10, + }, + ] + ), + }); + assert.equal(moved.history.undoDepth, 1); + assert.equal( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get()?.circuitId, + targetCircuit.id + ); + + createService(context).undo({ + projectId: "project-1", + expectedRevision: 1, + }); + assert.equal( + context.db + .select({ id: circuits.id }) + .from(circuits) + .where(eq(circuits.id, targetCircuit.id)) + .get(), + undefined + ); + assert.equal( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get()?.circuitId, + "circuit-1" + ); + } finally { + context.close(); + } + }); + it("rejects unavailable history directions without writing a revision", () => { const context = createTestDatabase(); try {