Keep single-device circuit names equal to their device row

A circuit with exactly one device row and no fed distribution board is
shown as that row, so its own name was hidden and could drift (e.g.
"Neuer Stromkreis" in the print view). Commands that create or leave
such a circuit, rename its row or unlink its downstream board now set
the circuit name to the row name and store the change as optional
circuitDisplayNames, so Undo restores an overwritten free name exactly.
Migration 0016 aligns existing data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-29 13:55:42 +01:00
parent 58cec53713
commit 01312b89fe
21 changed files with 4330 additions and 111 deletions

View file

@ -0,0 +1,23 @@
-- A circuit with exactly one device row and no fed distribution board is
-- shown as that row, so its hidden name follows the device row name.
UPDATE `circuits`
SET `display_name` = (
SELECT `circuit_device_rows`.`display_name`
FROM `circuit_device_rows`
WHERE `circuit_device_rows`.`circuit_id` = `circuits`.`id`
)
WHERE (
SELECT count(*)
FROM `circuit_device_rows`
WHERE `circuit_device_rows`.`circuit_id` = `circuits`.`id`
) = 1
AND NOT EXISTS (
SELECT 1
FROM `distribution_boards`
WHERE `distribution_boards`.`feeding_circuit_id` = `circuits`.`id`
)
AND `display_name` IS NOT (
SELECT `circuit_device_rows`.`display_name`
FROM `circuit_device_rows`
WHERE `circuit_device_rows`.`circuit_id` = `circuits`.`id`
);

File diff suppressed because it is too large Load diff

View file

@ -113,6 +113,13 @@
"when": 1790773066696,
"tag": "0015_wide_night_thrasher",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1793277078601,
"tag": "0016_single_device_circuit_names",
"breakpoints": true
}
]
}

View file

@ -10,6 +10,7 @@ import {
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 { invertCircuitDisplayNameTransitions } from "../../domain/models/circuit-display-name-sync.model.js";
import type {
CircuitDeviceRowMoveProjectCommandStore,
ExecuteCircuitDeviceRowMoveCommandInput,
@ -20,7 +21,11 @@ import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { syncSingleDeviceCircuitDisplayNames } from "./circuit-display-name-sync.persistence.js";
import {
executeProjectCommandTransactionWithAppliedForward,
type AppliedProjectCommand,
} from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
@ -30,6 +35,7 @@ interface PersistedMoveRow {
circuitId: string;
sortOrder: number;
phaseType: string | null;
displayName: string;
}
export class CircuitDeviceRowMoveProjectCommandRepository
@ -40,11 +46,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
execute(input: ExecuteCircuitDeviceRowMoveCommandInput) {
this.assertSupportedCommand(input.command);
return executeProjectCommandTransaction(
return executeProjectCommandTransactionWithAppliedForward(
this.database,
input,
(tx) =>
this.applyCommand(tx, input.projectId, input.command)
this.applyCommand(
tx,
input.projectId,
input.source,
input.command
)
);
}
@ -70,8 +81,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
private applyCommand(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitDeviceRowMoveCommandInput["source"],
command: CircuitDeviceRowMoveHistoryProjectCommand
): CircuitDeviceRowMoveHistoryProjectCommand {
): AppliedProjectCommand<
CircuitDeviceRowMoveHistoryProjectCommand,
CircuitDeviceRowMoveHistoryProjectCommand
> {
if (command.type === circuitDeviceRowMoveCommandType) {
const rowsById = this.loadExpectedRows(
database,
@ -85,21 +100,43 @@ export class CircuitDeviceRowMoveProjectCommandRepository
projectId,
circuitIds
);
const inverse = createCircuitDeviceRowMoveProjectCommand(
this.reverseMoves(command.payload.moves, rowsById)
);
this.applyMoves(database, command.payload.moves);
this.updateReserveStates(database, circuitIds);
this.updateVoltages(database, projectId, circuitIds);
return inverse;
const circuitDisplayNames = syncSingleDeviceCircuitDisplayNames(
database,
source,
circuitIds,
command.payload.circuitDisplayNames
);
return {
forward: createCircuitDeviceRowMoveProjectCommand(
command.payload.moves,
circuitDisplayNames
),
inverse: createCircuitDeviceRowMoveProjectCommand(
this.reverseMoves(command.payload.moves, rowsById),
invertCircuitDisplayNameTransitions(circuitDisplayNames)
),
};
}
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
command
);
const { targetCircuit, targetCircuitAction, moves } =
command.payload;
const { targetCircuitAction, moves } = command.payload;
const rowsById = this.loadExpectedRows(database, moves);
// A new circuit receiving exactly one row is shown as that row, so it
// takes the row's name instead of the client's placeholder name.
const targetCircuit =
source === "user" &&
targetCircuitAction === "create" &&
moves.length === 1
? {
...command.payload.targetCircuit,
displayName: rowsById.get(moves[0]!.rowId)!.displayName,
}
: command.payload.targetCircuit;
const appliedTargetCircuit = {
...targetCircuit,
voltage: resolveCircuitVoltage(
@ -124,12 +161,6 @@ export class CircuitDeviceRowMoveProjectCommandRepository
this.assertTargetCircuitAvailable(database, appliedTargetCircuit);
this.insertTargetCircuit(database, appliedTargetCircuit);
const inverse =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"delete",
appliedTargetCircuit,
this.reverseMoves(moves, rowsById)
);
this.applyMoves(database, moves);
this.updateReserveStates(database, [
...sourceCircuitIds,
@ -139,7 +170,26 @@ export class CircuitDeviceRowMoveProjectCommandRepository
...sourceCircuitIds,
targetCircuit.id,
]);
return inverse;
const circuitDisplayNames = syncSingleDeviceCircuitDisplayNames(
database,
source,
[...sourceCircuitIds, targetCircuit.id],
command.payload.circuitDisplayNames
);
return {
forward: createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
targetCircuit,
moves,
circuitDisplayNames
),
inverse: createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"delete",
appliedTargetCircuit,
this.reverseMoves(moves, rowsById),
invertCircuitDisplayNameTransitions(circuitDisplayNames)
),
};
}
this.assertTargetCircuitUnchanged(
@ -156,12 +206,6 @@ export class CircuitDeviceRowMoveProjectCommandRepository
destinationCircuitIds,
targetCircuit.circuitListId
);
const inverse =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
appliedTargetCircuit,
this.reverseMoves(moves, rowsById)
);
this.applyMoves(database, moves);
this.updateReserveStates(database, [
targetCircuit.id,
@ -172,6 +216,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
projectId,
destinationCircuitIds
);
const circuitDisplayNames = syncSingleDeviceCircuitDisplayNames(
database,
source,
destinationCircuitIds,
command.payload.circuitDisplayNames
);
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [
targetCircuit.id,
]);
@ -193,7 +243,20 @@ export class CircuitDeviceRowMoveProjectCommandRepository
"Created target circuit changed before deletion."
);
}
return inverse;
return {
forward: createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"delete",
targetCircuit,
moves,
circuitDisplayNames
),
inverse: createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
appliedTargetCircuit,
this.reverseMoves(moves, rowsById),
invertCircuitDisplayNameTransitions(circuitDisplayNames)
),
};
}
private updateVoltages(
@ -217,6 +280,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
circuitId: circuitDeviceRows.circuitId,
sortOrder: circuitDeviceRows.sortOrder,
phaseType: circuitDeviceRows.phaseType,
displayName: circuitDeviceRows.displayName,
})
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds))

View file

@ -6,6 +6,10 @@ import {
type CircuitDeviceRowUpdatePatch,
type CircuitDeviceRowUpdateValues,
} from "../../domain/models/circuit-device-row-project-command.model.js";
import {
assertCircuitDisplayNameTransitions,
invertCircuitDisplayNameTransitions,
} from "../../domain/models/circuit-display-name-sync.model.js";
import type {
CircuitDeviceRowProjectCommandStore,
ExecuteCircuitDeviceRowUpdateCommandInput,
@ -22,6 +26,7 @@ import {
toCircuitDeviceRowPatchValues,
type CircuitDeviceRowPatchInput,
} from "./circuit-device-row.persistence.js";
import { syncSingleDeviceCircuitDisplayNames } from "./circuit-display-name-sync.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
@ -34,6 +39,9 @@ export class CircuitDeviceRowProjectCommandRepository
executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) {
assertCircuitDeviceRowUpdateProjectCommand(input.command);
assertCircuitDisplayNameTransitions(
input.command.payload.circuitDisplayNames
);
return executeProjectCommandTransactionWithAppliedForward(
this.database,
@ -111,20 +119,16 @@ export class CircuitDeviceRowProjectCommandRepository
patch.overriddenFields = overriddenFields;
}
const appliedForward = createCircuitDeviceRowUpdateProjectCommand(
const appliedPatch = createCircuitDeviceRowUpdateProjectCommand(
current.id,
patch as CircuitDeviceRowUpdatePatch
);
const inversePatch = Object.fromEntries(
appliedForward.payload.changes.map((change) => [
appliedPatch.payload.changes.map((change) => [
change.field,
getCircuitDeviceRowFieldValue(current, change.field),
])
) as CircuitDeviceRowUpdatePatch;
const inverse = createCircuitDeviceRowUpdateProjectCommand(
current.id,
inversePatch
);
const update = tx
.update(circuitDeviceRows)
@ -141,10 +145,24 @@ export class CircuitDeviceRowProjectCommandRepository
current.circuitId
);
}
const circuitDisplayNames = syncSingleDeviceCircuitDisplayNames(
tx,
input.source,
patch.displayName === undefined ? [] : [current.circuitId],
input.command.payload.circuitDisplayNames
);
return {
forward: appliedForward,
inverse,
forward: createCircuitDeviceRowUpdateProjectCommand(
current.id,
patch as CircuitDeviceRowUpdatePatch,
circuitDisplayNames
),
inverse: createCircuitDeviceRowUpdateProjectCommand(
current.id,
inversePatch,
invertCircuitDisplayNameTransitions(circuitDisplayNames)
),
};
}

View file

@ -9,6 +9,10 @@ import {
type CircuitDeviceRowSnapshot,
type CircuitDeviceRowStructureProjectCommand,
} from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import {
invertCircuitDisplayNameTransitions,
type CircuitDisplayNameTransition,
} from "../../domain/models/circuit-display-name-sync.model.js";
import type {
CircuitDeviceRowStructureProjectCommandStore,
ExecuteCircuitDeviceRowStructureCommandInput,
@ -23,6 +27,7 @@ import {
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { syncSingleDeviceCircuitDisplayNames } from "./circuit-display-name-sync.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import {
@ -69,7 +74,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
projectId,
source,
command.payload.row,
command.payload.externalObjects ?? []
command.payload.externalObjects ?? [],
command.payload.circuitDisplayNames
);
}
if (command.type === circuitDeviceRowDeleteCommandType) {
@ -77,9 +83,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository
return this.delete(
database,
projectId,
source,
command.payload.rowId,
command.payload.expectedCircuitId,
command.payload.externalObjects
command.payload.externalObjects,
command.payload.circuitDisplayNames
);
}
throw new Error("Unsupported circuit device-row structure command.");
@ -90,7 +98,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
row: CircuitDeviceRowSnapshot,
externalObjects: ExternalObjectLinkTransition[]
externalObjects: ExternalObjectLinkTransition[],
storedCircuitDisplayNames: CircuitDisplayNameTransition[] | undefined
) {
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
throw new Error("Device-row phase type is invalid.");
@ -125,19 +134,32 @@ export class CircuitDeviceRowStructureProjectCommandRepository
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
}
const circuitDisplayNames = syncSingleDeviceCircuitDisplayNames(
database,
source,
[row.circuitId],
storedCircuitDisplayNames
);
return {
forward: createCircuitDeviceRowInsertProjectCommand(row, externalObjects),
inverse: createCircuitDeviceRowDeleteProjectCommand(row.id, row.circuitId, invertExternalObjectLinkTransitions(externalObjects)),
forward: createCircuitDeviceRowInsertProjectCommand(row, externalObjects, circuitDisplayNames),
inverse: createCircuitDeviceRowDeleteProjectCommand(
row.id,
row.circuitId,
invertExternalObjectLinkTransitions(externalObjects),
invertCircuitDisplayNameTransitions(circuitDisplayNames)
),
};
}
private delete(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
rowId: string,
expectedCircuitId: string,
suppliedExternalObjects?: ExternalObjectLinkTransition[]
suppliedExternalObjects: ExternalObjectLinkTransition[] | undefined,
storedCircuitDisplayNames: CircuitDisplayNameTransition[] | undefined
) {
const row = database
.select()
@ -187,10 +209,20 @@ export class CircuitDeviceRowStructureProjectCommandRepository
projectId,
expectedCircuitId
);
const circuitDisplayNames = syncSingleDeviceCircuitDisplayNames(
database,
source,
[expectedCircuitId],
storedCircuitDisplayNames
);
return {
forward: createCircuitDeviceRowDeleteProjectCommand(rowId, expectedCircuitId, externalObjects),
inverse: createCircuitDeviceRowInsertProjectCommand(toCircuitDeviceRowSnapshot(row), invertExternalObjectLinkTransitions(externalObjects)),
forward: createCircuitDeviceRowDeleteProjectCommand(rowId, expectedCircuitId, externalObjects, circuitDisplayNames),
inverse: createCircuitDeviceRowInsertProjectCommand(
toCircuitDeviceRowSnapshot(row),
invertExternalObjectLinkTransitions(externalObjects),
invertCircuitDisplayNameTransitions(circuitDisplayNames)
),
};
}

View file

@ -0,0 +1,89 @@
import { and, eq, isNull } from "drizzle-orm";
import {
resolveSingleDeviceCircuitDisplayName,
type CircuitDisplayNameTransition,
} from "../../domain/models/circuit-display-name-sync.model.js";
import type { ProjectRevisionSource } from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
/**
* Keeps the name of single-device circuits equal to their device row name.
* A user command derives the changes for the affected circuits after its own
* write; history replays apply exactly the stored changes. Commands stored
* before this rule carry no changes and keep their original behavior.
*/
export function syncSingleDeviceCircuitDisplayNames(
database: AppDatabase,
source: ProjectRevisionSource,
circuitIds: Iterable<string>,
storedTransitions: readonly CircuitDisplayNameTransition[] | undefined
): CircuitDisplayNameTransition[] {
if (source !== "user") {
const transitions = [...(storedTransitions ?? [])];
for (const transition of transitions) {
applyCircuitDisplayNameTransition(database, transition);
}
return transitions;
}
const transitions: CircuitDisplayNameTransition[] = [];
for (const circuitId of new Set(circuitIds)) {
const circuit = database
.select({ displayName: circuits.displayName })
.from(circuits)
.where(eq(circuits.id, circuitId))
.get();
if (!circuit) continue;
const deviceRowDisplayNames = database
.select({ displayName: circuitDeviceRows.displayName })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuitId))
.limit(2)
.all()
.map((row) => row.displayName);
const fedBoard = database
.select({ id: distributionBoards.id })
.from(distributionBoards)
.where(eq(distributionBoards.feedingCircuitId, circuitId))
.limit(1)
.get();
const target = resolveSingleDeviceCircuitDisplayName({
displayName: circuit.displayName,
deviceRowDisplayNames,
feedsDistributionBoard: fedBoard !== undefined,
});
if (target === null) continue;
const transition = {
circuitId,
expected: circuit.displayName,
target,
};
applyCircuitDisplayNameTransition(database, transition);
transitions.push(transition);
}
return transitions;
}
function applyCircuitDisplayNameTransition(
database: AppDatabase,
transition: CircuitDisplayNameTransition
) {
const updated = database
.update(circuits)
.set({ displayName: transition.target })
.where(
and(
eq(circuits.id, transition.circuitId),
transition.expected === null
? isNull(circuits.displayName)
: eq(circuits.displayName, transition.expected)
)
)
.run();
if (updated.changes !== 1) {
throw new Error("Circuit name changed before command execution.");
}
}

View file

@ -106,9 +106,17 @@ export class CircuitStructureProjectCommandRepository
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
snapshot: CircuitSnapshot,
requestedSnapshot: CircuitSnapshot,
externalObjects: ExternalObjectLinkTransition[]
) {
// A single-device circuit is shown as its device row and carries its name.
const snapshot =
source === "user" && requestedSnapshot.deviceRows.length === 1
? {
...requestedSnapshot,
displayName: requestedSnapshot.deviceRows[0]!.displayName,
}
: requestedSnapshot;
this.assertCircuitLocation(database, projectId, snapshot);
if (source === "user") {
const derivedVoltage = resolveCircuitVoltage(

View file

@ -30,6 +30,7 @@ import {
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
} from "../../domain/models/circuit-structure-project-command.model.js";
import { invertCircuitDisplayNameTransitions } from "../../domain/models/circuit-display-name-sync.model.js";
import type {
DistributionBoardStructureProjectCommandStore,
ExecuteDistributionBoardStructureCommandInput,
@ -42,7 +43,12 @@ import { distributionBoardComponents } from "../schema/distribution-board-compon
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { syncSingleDeviceCircuitDisplayNames } from "./circuit-display-name-sync.persistence.js";
import {
executeProjectCommandTransaction,
executeProjectCommandTransactionWithAppliedForward,
type AppliedProjectCommand,
} from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js";
@ -70,7 +76,7 @@ export class DistributionBoardStructureProjectCommandRepository
}
) {
assertDistributionBoardUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(
return executeProjectCommandTransactionWithAppliedForward(
this.database,
input,
(tx) => this.update(tx, input)
@ -94,7 +100,7 @@ export class DistributionBoardStructureProjectCommandRepository
"Generated feeder circuits may only be removed through project history."
);
}
return executeProjectCommandTransaction(
return executeProjectCommandTransactionWithAppliedForward(
this.database,
input,
(tx) => this.applyFeederCircuitCommand(tx, input)
@ -384,11 +390,6 @@ export class DistributionBoardStructureProjectCommandRepository
getDistributionBoardFieldValue(current, change.field),
])
) as DistributionBoardUpdatePatch;
const inverse = createDistributionBoardUpdateProjectCommand(
current.id,
inversePatch,
input.command.schemaVersion
);
const updated = database
.update(distributionBoards)
.set(patch)
@ -404,7 +405,26 @@ export class DistributionBoardStructureProjectCommandRepository
"Distribution board changed before command execution."
);
}
return inverse;
const circuitDisplayNames = syncSingleDeviceCircuitDisplayNames(
database,
input.source,
current.feedingCircuitId === null ? [] : [current.feedingCircuitId],
input.command.payload.circuitDisplayNames
);
return {
forward: createDistributionBoardUpdateProjectCommand(
current.id,
patch,
input.command.schemaVersion,
circuitDisplayNames
),
inverse: createDistributionBoardUpdateProjectCommand(
current.id,
inversePatch,
input.command.schemaVersion,
invertCircuitDisplayNameTransitions(circuitDisplayNames)
),
};
}
private applyFeederCircuitCommand(
@ -415,7 +435,10 @@ export class DistributionBoardStructureProjectCommandRepository
> & {
command: DistributionBoardFeederCircuitProjectCommand;
}
): DistributionBoardFeederCircuitProjectCommand {
): AppliedProjectCommand<
DistributionBoardFeederCircuitProjectCommand,
DistributionBoardFeederCircuitProjectCommand
> {
const circuitRepository =
new CircuitStructureProjectCommandRepository(database);
const { circuit, boardUpdate } = input.command.payload;
@ -431,17 +454,23 @@ export class DistributionBoardStructureProjectCommandRepository
if (circuitInverse.type !== circuitDeleteCommandType) {
throw new Error("Created feeder circuit returned an invalid inverse.");
}
const boardInverse = this.update(database, {
const board = this.update(database, {
...input,
command: boardUpdate,
});
return createDistributionBoardRemoveFeederCircuitProjectCommand(
circuit,
boardInverse
);
return {
forward: createDistributionBoardCreateFeederCircuitProjectCommand(
circuit,
board.forward
),
inverse: createDistributionBoardRemoveFeederCircuitProjectCommand(
circuit,
board.inverse
),
};
}
const boardInverse = this.update(database, {
const board = this.update(database, {
...input,
command: boardUpdate,
});
@ -459,10 +488,16 @@ export class DistributionBoardStructureProjectCommandRepository
"Generated feeder circuit changed before history removal."
);
}
return createDistributionBoardCreateFeederCircuitProjectCommand(
circuit,
boardInverse
);
return {
forward: createDistributionBoardRemoveFeederCircuitProjectCommand(
circuit,
board.forward
),
inverse: createDistributionBoardCreateFeederCircuitProjectCommand(
circuit,
board.inverse
),
};
}
private assertFeedingReference(

View file

@ -2,6 +2,7 @@ import { eq } from "drizzle-orm";
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
import {
assertExternalObjectNewRowProjectCommand,
createExternalObjectNewRowProjectCommand,
externalObjectAssignToNewRowCommandType,
externalObjectDeleteCreatedRowCommandType,
invertExternalObjectNewRowProjectCommand,
@ -20,7 +21,8 @@ import {
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { syncSingleDeviceCircuitDisplayNames } from "./circuit-display-name-sync.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import {
applyExternalObjectLinks,
@ -42,13 +44,26 @@ export class ExternalObjectNewRowProjectCommandRepository
) {
throw new Error("Created external rows may only be removed through project history.");
}
return executeProjectCommandTransaction(this.database, input, (tx) => {
return executeProjectCommandTransactionWithAppliedForward(this.database, input, (tx) => {
if (input.command.type === externalObjectAssignToNewRowCommandType) {
this.insert(tx, input.projectId, input.command);
} else {
this.remove(tx, input.projectId, input.command);
}
return invertExternalObjectNewRowProjectCommand(input.command);
const circuitDisplayNames = syncSingleDeviceCircuitDisplayNames(
tx,
input.source,
[input.command.payload.row.circuitId],
input.command.payload.circuitDisplayNames
);
const forward = createExternalObjectNewRowProjectCommand(
input.command.type,
{ ...input.command.payload, circuitDisplayNames }
);
return {
forward,
inverse: invertExternalObjectNewRowProjectCommand(forward),
};
});
}

View file

@ -6,6 +6,7 @@ import {
projectDeviceSyncRowSnapshotFields,
type ProjectDeviceSyncRowSnapshot,
} from "../../domain/models/project-device-row-sync-project-command.model.js";
import { invertCircuitDisplayNameTransitions } from "../../domain/models/circuit-display-name-sync.model.js";
import type {
ExecuteProjectDeviceRowSyncCommandInput,
ProjectDeviceRowSyncProjectCommandStore,
@ -16,7 +17,8 @@ import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { projectDevices } from "../schema/project-devices.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { syncSingleDeviceCircuitDisplayNames } from "./circuit-display-name-sync.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
@ -29,7 +31,7 @@ export class ProjectDeviceRowSyncProjectCommandRepository
execute(input: ExecuteProjectDeviceRowSyncCommandInput) {
assertProjectDeviceRowSyncProjectCommand(input.command);
return executeProjectCommandTransaction(
return executeProjectCommandTransactionWithAppliedForward(
this.database,
input,
(tx) => this.applyCommand(tx, input)
@ -107,18 +109,6 @@ export class ProjectDeviceRowSyncProjectCommandRepository
}
}
const inverse = createProjectDeviceRowSyncProjectCommand(
projectDevice.id,
invertProjectDeviceRowSyncOperation(
input.command.payload.operation
),
input.command.payload.rows.map((row) => ({
rowId: row.rowId,
expected: row.target,
target: row.expected,
}))
);
for (const assignment of input.command.payload.rows) {
const values: typeof assignment.target & {
manualQuantity?: number;
@ -173,8 +163,40 @@ export class ProjectDeviceRowSyncProjectCommandRepository
circuitId
);
}
const circuitDisplayNames = syncSingleDeviceCircuitDisplayNames(
tx,
input.source,
input.command.payload.rows
.filter(
(assignment) =>
assignment.expected.displayName !==
assignment.target.displayName
)
.map(
(assignment) => persistedById.get(assignment.rowId)!.circuitId
),
input.command.payload.circuitDisplayNames
);
return inverse;
const { projectDeviceId, operation, rows } = input.command.payload;
return {
forward: createProjectDeviceRowSyncProjectCommand(
projectDeviceId,
operation,
rows,
circuitDisplayNames
),
inverse: createProjectDeviceRowSyncProjectCommand(
projectDevice.id,
invertProjectDeviceRowSyncOperation(operation),
rows.map((row) => ({
rowId: row.rowId,
expected: row.target,
target: row.expected,
})),
invertCircuitDisplayNameTransitions(circuitDisplayNames)
),
};
}
}

View file

@ -1,4 +1,9 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
assertCircuitDisplayNameTransitions,
withCircuitDisplayNames,
type CircuitDisplayNameTransition,
} from "./circuit-display-name-sync.model.js";
import {
assertCircuitInsertProjectCommand,
circuitInsertCommandType,
@ -22,6 +27,7 @@ export interface CircuitDeviceRowMoveAssignment {
export interface CircuitDeviceRowMoveCommandPayload {
moves: CircuitDeviceRowMoveAssignment[];
circuitDisplayNames?: CircuitDisplayNameTransition[];
}
export interface CircuitDeviceRowMoveProjectCommand
@ -38,6 +44,7 @@ export interface CircuitDeviceRowMoveWithNewCircuitCommandPayload {
targetCircuitAction: CircuitDeviceRowMoveTargetCircuitAction;
targetCircuit: CircuitSnapshot;
moves: CircuitDeviceRowMoveAssignment[];
circuitDisplayNames?: CircuitDisplayNameTransition[];
}
export interface CircuitDeviceRowMoveWithNewCircuitProjectCommand
@ -51,12 +58,13 @@ export type CircuitDeviceRowMoveHistoryProjectCommand =
| CircuitDeviceRowMoveWithNewCircuitProjectCommand;
export function createCircuitDeviceRowMoveProjectCommand(
moves: CircuitDeviceRowMoveAssignment[]
moves: CircuitDeviceRowMoveAssignment[],
circuitDisplayNames?: CircuitDisplayNameTransition[]
): CircuitDeviceRowMoveProjectCommand {
const command: CircuitDeviceRowMoveProjectCommand = {
schemaVersion: circuitDeviceRowMoveCommandSchemaVersion,
type: circuitDeviceRowMoveCommandType,
payload: { moves },
payload: withCircuitDisplayNames({ moves }, circuitDisplayNames),
};
assertCircuitDeviceRowMoveProjectCommand(command);
return command;
@ -65,16 +73,20 @@ export function createCircuitDeviceRowMoveProjectCommand(
export function createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
targetCircuitAction: CircuitDeviceRowMoveTargetCircuitAction,
targetCircuit: CircuitSnapshot,
moves: CircuitDeviceRowMoveAssignment[]
moves: CircuitDeviceRowMoveAssignment[],
circuitDisplayNames?: CircuitDisplayNameTransition[]
): CircuitDeviceRowMoveWithNewCircuitProjectCommand {
const command: CircuitDeviceRowMoveWithNewCircuitProjectCommand = {
schemaVersion: circuitDeviceRowMoveCommandSchemaVersion,
type: circuitDeviceRowMoveWithNewCircuitCommandType,
payload: {
targetCircuitAction,
targetCircuit,
moves,
},
payload: withCircuitDisplayNames(
{
targetCircuitAction,
targetCircuit,
moves,
},
circuitDisplayNames
),
};
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(command);
return command;
@ -135,6 +147,7 @@ export function assertCircuitDeviceRowMoveProjectCommand(
rowIds.add(move.rowId);
}
assertCircuitDisplayNameTransitions(command.payload.circuitDisplayNames);
}
export function assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
@ -205,6 +218,7 @@ export function assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
);
}
}
assertCircuitDisplayNameTransitions(command.payload.circuitDisplayNames);
}
function assertNonEmptyString(

View file

@ -1,3 +1,6 @@
// The frontend imports this model as a value, so it keeps type-only imports.
// The repository validates the stored circuitDisplayNames.
import type { CircuitDisplayNameTransition } from "./circuit-display-name-sync.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitDeviceRowUpdateCommandType =
@ -40,6 +43,7 @@ export interface CircuitDeviceRowUpdateFieldChange<
export interface CircuitDeviceRowUpdateCommandPayload {
rowId: string;
changes: CircuitDeviceRowUpdateFieldChange[];
circuitDisplayNames?: CircuitDisplayNameTransition[];
}
export interface CircuitDeviceRowUpdateProjectCommand
@ -50,7 +54,8 @@ export interface CircuitDeviceRowUpdateProjectCommand
export function createCircuitDeviceRowUpdateProjectCommand(
rowId: string,
patch: CircuitDeviceRowUpdatePatch
patch: CircuitDeviceRowUpdatePatch,
circuitDisplayNames?: CircuitDisplayNameTransition[]
): CircuitDeviceRowUpdateProjectCommand {
const normalizedPatch =
patch.quantity !== undefined && patch.manualQuantity === undefined
@ -70,7 +75,9 @@ export function createCircuitDeviceRowUpdateProjectCommand(
const command: CircuitDeviceRowUpdateProjectCommand = {
schemaVersion: circuitDeviceRowUpdateCommandSchemaVersion,
type: circuitDeviceRowUpdateCommandType,
payload: { rowId, changes },
payload: circuitDisplayNames?.length
? { rowId, changes, circuitDisplayNames }
: { rowId, changes },
};
assertCircuitDeviceRowUpdateProjectCommand(command);
return command;

View file

@ -1,4 +1,9 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
assertCircuitDisplayNameTransitions,
withCircuitDisplayNames,
type CircuitDisplayNameTransition,
} from "./circuit-display-name-sync.model.js";
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
@ -40,12 +45,14 @@ export interface CircuitDeviceRowSnapshot {
export interface CircuitDeviceRowInsertCommandPayload {
row: CircuitDeviceRowSnapshot;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
circuitDisplayNames?: CircuitDisplayNameTransition[];
}
export interface CircuitDeviceRowDeleteCommandPayload {
rowId: string;
expectedCircuitId: string;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
circuitDisplayNames?: CircuitDisplayNameTransition[];
}
export interface CircuitDeviceRowInsertProjectCommand
@ -66,7 +73,8 @@ export type CircuitDeviceRowStructureProjectCommand =
export function createCircuitDeviceRowInsertProjectCommand(
row: CircuitDeviceRowSnapshot,
externalObjects?: ExternalObjectLinkSnapshotTransition[]
externalObjects?: ExternalObjectLinkSnapshotTransition[],
circuitDisplayNames?: CircuitDisplayNameTransition[]
): CircuitDeviceRowInsertProjectCommand {
const normalizedRow = {
...row,
@ -75,7 +83,10 @@ export function createCircuitDeviceRowInsertProjectCommand(
const command: CircuitDeviceRowInsertProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType,
payload: { row: normalizedRow, ...(externalObjects?.length ? { externalObjects } : {}) },
payload: withCircuitDisplayNames(
{ row: normalizedRow, ...(externalObjects?.length ? { externalObjects } : {}) },
circuitDisplayNames
),
};
assertCircuitDeviceRowInsertProjectCommand(command);
return command;
@ -84,12 +95,16 @@ export function createCircuitDeviceRowInsertProjectCommand(
export function createCircuitDeviceRowDeleteProjectCommand(
rowId: string,
expectedCircuitId: string,
externalObjects?: ExternalObjectLinkSnapshotTransition[]
externalObjects?: ExternalObjectLinkSnapshotTransition[],
circuitDisplayNames?: CircuitDisplayNameTransition[]
): CircuitDeviceRowDeleteProjectCommand {
const command: CircuitDeviceRowDeleteProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowDeleteCommandType,
payload: { rowId, expectedCircuitId, ...(externalObjects?.length ? { externalObjects } : {}) },
payload: withCircuitDisplayNames(
{ rowId, expectedCircuitId, ...(externalObjects?.length ? { externalObjects } : {}) },
circuitDisplayNames
),
};
assertCircuitDeviceRowDeleteProjectCommand(command);
return command;
@ -155,6 +170,7 @@ export function assertCircuitDeviceRowInsertProjectCommand(
}
}
assertExternalObjectLinkTransitions(command.payload.externalObjects);
assertCircuitDisplayNameTransitions(command.payload.circuitDisplayNames);
}
export function assertCircuitDeviceRowDeleteProjectCommand(
@ -176,6 +192,7 @@ export function assertCircuitDeviceRowDeleteProjectCommand(
"expectedCircuitId"
);
assertExternalObjectLinkTransitions(command.payload.externalObjects);
assertCircuitDisplayNameTransitions(command.payload.circuitDisplayNames);
}
export function assertExternalObjectLinkTransitions(value: unknown) {

View file

@ -0,0 +1,99 @@
/**
* A circuit with exactly one device row and no fed distribution board is
* shown as one compact row, so its own name is not visible or editable there.
* Its name therefore always follows that device row's name. Commands that
* change this state store the resulting circuit-name changes, so Undo/Redo
* restore them exactly, including a free name that was overwritten when only
* one device row remained.
*/
export interface CircuitDisplayNameTransition {
circuitId: string;
expected: string | null;
target: string | null;
}
/**
* Returns the name a circuit must carry, or `null` when its current name is
* already correct or freely editable.
*/
export function resolveSingleDeviceCircuitDisplayName(input: {
displayName: string | null;
deviceRowDisplayNames: readonly string[];
feedsDistributionBoard: boolean;
}): string | null {
if (
input.deviceRowDisplayNames.length !== 1 ||
input.feedsDistributionBoard
) {
return null;
}
const deviceRowDisplayName = input.deviceRowDisplayNames[0]!;
return input.displayName === deviceRowDisplayName
? null
: deviceRowDisplayName;
}
export function invertCircuitDisplayNameTransitions(
transitions: readonly CircuitDisplayNameTransition[]
): CircuitDisplayNameTransition[] {
return [...transitions].reverse().map((transition) => ({
circuitId: transition.circuitId,
expected: transition.target,
target: transition.expected,
}));
}
/** Sets the optional payload field only when it carries changes. */
export function withCircuitDisplayNames<Payload extends object>(
payload: Payload,
circuitDisplayNames: readonly CircuitDisplayNameTransition[] | undefined
): Payload & { circuitDisplayNames?: CircuitDisplayNameTransition[] } {
const { circuitDisplayNames: _previous, ...rest } = payload as Payload & {
circuitDisplayNames?: unknown;
};
return (
circuitDisplayNames?.length
? { ...rest, circuitDisplayNames: [...circuitDisplayNames] }
: rest
) as Payload & { circuitDisplayNames?: CircuitDisplayNameTransition[] };
}
export function assertCircuitDisplayNameTransitions(
value: unknown
): asserts value is CircuitDisplayNameTransition[] | undefined {
if (value === undefined) return;
if (!Array.isArray(value) || value.length === 0) {
throw new Error("circuitDisplayNames must be a non-empty array.");
}
const circuitIds = new Set<string>();
for (const transition of value) {
if (
transition === null ||
typeof transition !== "object" ||
Array.isArray(transition)
) {
throw new Error("Invalid circuit display-name transition.");
}
const { circuitId, expected, target } = transition as Record<
string,
unknown
>;
if (
typeof circuitId !== "string" ||
!circuitId.trim() ||
circuitIds.has(circuitId)
) {
throw new Error(
"Circuit display-name transition has an invalid or duplicate circuit id."
);
}
if (
(expected !== null && typeof expected !== "string") ||
(target !== null && typeof target !== "string") ||
expected === target
) {
throw new Error("Invalid circuit display-name transition values.");
}
circuitIds.add(circuitId);
}
}

View file

@ -5,6 +5,11 @@ import {
type DistributionBoardInfoField,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import {
assertCircuitDisplayNameTransitions,
withCircuitDisplayNames,
type CircuitDisplayNameTransition,
} from "./circuit-display-name-sync.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardUpdateCommandType =
@ -39,6 +44,8 @@ export interface DistributionBoardUpdateFieldChange<
export interface DistributionBoardUpdateCommandPayload {
distributionBoardId: string;
changes: DistributionBoardUpdateFieldChange[];
// Single-device feeding circuits that become compact rows take their row name.
circuitDisplayNames?: CircuitDisplayNameTransition[];
}
export interface DistributionBoardUpdateProjectCommand
@ -53,18 +60,22 @@ export function createDistributionBoardUpdateProjectCommand(
distributionBoardId: string,
patch: DistributionBoardUpdatePatch,
schemaVersion: DistributionBoardUpdateProjectCommand["schemaVersion"] =
distributionBoardUpdateCommandSchemaVersion
distributionBoardUpdateCommandSchemaVersion,
circuitDisplayNames?: CircuitDisplayNameTransition[]
): DistributionBoardUpdateProjectCommand {
const command: DistributionBoardUpdateProjectCommand = {
schemaVersion,
type: distributionBoardUpdateCommandType,
payload: {
distributionBoardId,
changes: Object.entries(patch).map(([field, value]) => ({
field: field as DistributionBoardUpdateField,
value,
})) as DistributionBoardUpdateFieldChange[],
},
payload: withCircuitDisplayNames(
{
distributionBoardId,
changes: Object.entries(patch).map(([field, value]) => ({
field: field as DistributionBoardUpdateField,
value,
})) as DistributionBoardUpdateFieldChange[],
},
circuitDisplayNames
),
};
assertDistributionBoardUpdateProjectCommand(command);
return command;
@ -154,6 +165,7 @@ export function assertDistributionBoardUpdateProjectCommand(
}
seen.add(change.field);
}
assertCircuitDisplayNameTransitions(command.payload.circuitDisplayNames);
}
function isInfoField(field: string): field is DistributionBoardInfoField {

View file

@ -5,6 +5,12 @@ import {
circuitDeviceRowStructureCommandSchemaVersion,
type CircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure-project-command.model.js";
import {
assertCircuitDisplayNameTransitions,
invertCircuitDisplayNameTransitions,
withCircuitDisplayNames,
type CircuitDisplayNameTransition,
} from "./circuit-display-name-sync.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
@ -21,6 +27,7 @@ export interface ExternalObjectNewRowPayload {
target: ExternalModelObjectSnapshot;
}>;
confirmedConflictObjectIds: string[];
circuitDisplayNames?: CircuitDisplayNameTransition[];
}
export interface ExternalObjectNewRowProjectCommand
@ -38,14 +45,17 @@ export function createExternalObjectNewRowProjectCommand(
const command: ExternalObjectNewRowProjectCommand = {
schemaVersion: externalObjectNewRowCommandSchemaVersion,
type,
payload: {
row: {
...payload.row,
manualQuantity: payload.row.manualQuantity ?? payload.row.quantity,
payload: withCircuitDisplayNames(
{
row: {
...payload.row,
manualQuantity: payload.row.manualQuantity ?? payload.row.quantity,
},
objects: payload.objects,
confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds],
},
objects: payload.objects,
confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds],
},
payload.circuitDisplayNames
),
};
assertExternalObjectNewRowProjectCommand(command);
return command;
@ -65,6 +75,9 @@ export function invertExternalObjectNewRowProjectCommand(
target: expected,
})),
confirmedConflictObjectIds: command.payload.confirmedConflictObjectIds,
circuitDisplayNames: invertCircuitDisplayNameTransitions(
command.payload.circuitDisplayNames ?? []
),
}
);
}
@ -112,6 +125,7 @@ export function assertExternalObjectNewRowProjectCommand(
}
confirmedIds.add(objectId);
}
assertCircuitDisplayNameTransitions(command.payload.circuitDisplayNames);
}
function parseRow(value: unknown): CircuitDeviceRowSnapshot {

View file

@ -5,6 +5,11 @@ import {
type CircuitDeviceRowUpdateField,
type CircuitDeviceRowUpdateValues,
} from "./circuit-device-row-project-command.model.js";
import {
assertCircuitDisplayNameTransitions,
withCircuitDisplayNames,
type CircuitDisplayNameTransition,
} from "./circuit-display-name-sync.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectDeviceRowSyncCommandType =
@ -47,6 +52,7 @@ export interface ProjectDeviceRowSyncCommandPayload {
projectDeviceId: string;
operation: ProjectDeviceRowSyncOperation;
rows: ProjectDeviceRowSyncAssignment[];
circuitDisplayNames?: CircuitDisplayNameTransition[];
}
export interface ProjectDeviceRowSyncProjectCommand
@ -58,12 +64,16 @@ export interface ProjectDeviceRowSyncProjectCommand
export function createProjectDeviceRowSyncProjectCommand(
projectDeviceId: string,
operation: ProjectDeviceRowSyncOperation,
rows: ProjectDeviceRowSyncAssignment[]
rows: ProjectDeviceRowSyncAssignment[],
circuitDisplayNames?: CircuitDisplayNameTransition[]
): ProjectDeviceRowSyncProjectCommand {
const command: ProjectDeviceRowSyncProjectCommand = {
schemaVersion: projectDeviceRowSyncCommandSchemaVersion,
type: projectDeviceRowSyncCommandType,
payload: { projectDeviceId, operation, rows },
payload: withCircuitDisplayNames(
{ projectDeviceId, operation, rows },
circuitDisplayNames
),
};
assertProjectDeviceRowSyncProjectCommand(command);
return command;
@ -131,6 +141,7 @@ export function assertProjectDeviceRowSyncProjectCommand(
);
rowIds.add(row.rowId);
}
assertCircuitDisplayNameTransitions(command.payload.circuitDisplayNames);
}
export function invertProjectDeviceRowSyncOperation(

View file

@ -224,7 +224,8 @@ export async function assignExternalObjectsToNewCircuit(req: Request, res: Respo
circuitListId: sectionLocation.tree.circuitListId,
sectionId,
equipmentIdentifier: await circuitNumberingService.getNextIdentifier(sectionId),
displayName: null,
// The single external row is shown compactly, so the circuit carries its name.
displayName: row.displayName,
sortOrder: Math.max(0, ...sectionLocation.section.circuits.map((entry) => entry.sortOrder)) + 10,
cableType: null, cableCrossSection: null, cableLength: null, rcdAssignment: null,
terminalDesignation: null,