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
@@ -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({