Add placeholder move history

This commit is contained in:
2026-07-23 22:39:59 +02:00
parent 0a51703315
commit 53282e8c7c
12 changed files with 1106 additions and 160 deletions
+3 -2
View File
@@ -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:
+15 -11
View File
@@ -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:
+7 -3
View File
@@ -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
@@ -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
@@ -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
@@ -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<string, PersistedMoveRow>
) {
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."
);
}
}
}
}
@@ -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<CircuitDeviceRowMoveWithNewCircuitCommandPayload> {
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<unknown>
): asserts command is CircuitDeviceRowMoveProjectCommand {
@@ -91,6 +137,76 @@ export function assertCircuitDeviceRowMoveProjectCommand(
}
}
export function assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
command: SerializedProjectCommand<unknown>
): 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
@@ -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 {
@@ -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({
@@ -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> = {}
): 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();
}
});
});
+106
View File
@@ -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", () => {
+89 -1
View File
@@ -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 {