Protect referenced feeder structures from deletion

This commit is contained in:
Julian Appel 2026-09-27 22:25:47 +02:00
parent b893c72534
commit 855a04e655
13 changed files with 369 additions and 0 deletions

View file

@ -23,6 +23,7 @@ import { circuits } from "../schema/circuits.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
interface PersistedMoveRow { interface PersistedMoveRow {
id: string; id: string;
@ -171,6 +172,9 @@ export class CircuitDeviceRowMoveProjectCommandRepository
projectId, projectId,
destinationCircuitIds destinationCircuitIds
); );
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [
targetCircuit.id,
]);
const deleted = database const deleted = database
.delete(circuits) .delete(circuits)
.where( .where(

View file

@ -26,6 +26,7 @@ import { distributionBoardComponentProtectionDevices } from "../schema/distribut
import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js"; import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
export class CircuitGroupSubtreeProjectCommandRepository export class CircuitGroupSubtreeProjectCommandRepository
implements CircuitGroupSubtreeProjectCommandStore implements CircuitGroupSubtreeProjectCommandStore
@ -65,6 +66,11 @@ export class CircuitGroupSubtreeProjectCommandRepository
"Circuit-group subtree changed before deletion." "Circuit-group subtree changed before deletion."
); );
} }
assertCircuitsAreNotReferencedAsFeeders(
database,
projectId,
current.circuits.map((entry) => entry.circuit.id)
);
const deleted = database const deleted = database
.delete(circuitSections) .delete(circuitSections)
.where(eq(circuitSections.id, current.group.id)) .where(eq(circuitSections.id, current.group.id))

View file

@ -26,6 +26,7 @@ import {
toCircuitDeviceRowSnapshot, toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js"; } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
@ -236,6 +237,8 @@ export class CircuitStructureProjectCommandRepository
protectionDevice: protectionDevice ?? null, protectionDevice: protectionDevice ?? null,
}); });
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [circuit.id]);
const result = database const result = database
.delete(circuits) .delete(circuits)
.where( .where(

View file

@ -0,0 +1,65 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { distributionBoards } from "../schema/distribution-boards.js";
export function assertBoardsAreNotReferencedAsFeeders(
database: AppDatabase,
projectId: string,
distributionBoardIds: string[]
) {
if (distributionBoardIds.length === 0) return;
const dependent = database
.select({
name: distributionBoards.name,
boardNumber: distributionBoards.boardNumber,
})
.from(distributionBoards)
.where(
and(
eq(distributionBoards.projectId, projectId),
inArray(
distributionBoards.feedingDistributionBoardId,
[...new Set(distributionBoardIds)]
)
)
)
.orderBy(asc(distributionBoards.boardNumber), asc(distributionBoards.name))
.limit(1)
.get();
if (dependent) throwFeedingReferenceError(dependent);
}
export function assertCircuitsAreNotReferencedAsFeeders(
database: AppDatabase,
projectId: string,
circuitIds: string[]
) {
if (circuitIds.length === 0) return;
const dependent = database
.select({
name: distributionBoards.name,
boardNumber: distributionBoards.boardNumber,
})
.from(distributionBoards)
.where(
and(
eq(distributionBoards.projectId, projectId),
inArray(
distributionBoards.feedingCircuitId,
[...new Set(circuitIds)]
)
)
)
.orderBy(asc(distributionBoards.boardNumber), asc(distributionBoards.name))
.limit(1)
.get();
if (dependent) throwFeedingReferenceError(dependent);
}
function throwFeedingReferenceError(dependent: {
name: string;
boardNumber: string | null;
}): never {
const label = dependent.boardNumber ?? dependent.name;
throw new Error(`Speist ${label} – erst Einspeisung ändern.`);
}

View file

@ -30,6 +30,7 @@ import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js"; import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
export class DistributionBoardStructureProjectCommandRepository export class DistributionBoardStructureProjectCommandRepository
implements DistributionBoardStructureProjectCommandStore implements DistributionBoardStructureProjectCommandStore
@ -248,6 +249,8 @@ export class DistributionBoardStructureProjectCommandRepository
); );
} }
assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]);
const deleted = database const deleted = database
.delete(distributionBoards) .delete(distributionBoards)
.where( .where(

View file

@ -32,6 +32,7 @@ import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js"; import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
export class DistributionBoardSubtreeProjectCommandRepository export class DistributionBoardSubtreeProjectCommandRepository
implements DistributionBoardSubtreeProjectCommandStore implements DistributionBoardSubtreeProjectCommandStore
@ -82,6 +83,9 @@ export class DistributionBoardSubtreeProjectCommandRepository
"Distribution-board subtree changed before deletion." "Distribution-board subtree changed before deletion."
); );
} }
assertBoardsAreNotReferencedAsFeeders(database, projectId, [
current.distributionBoard.id,
]);
const deleted = database const deleted = database
.delete(distributionBoards) .delete(distributionBoards)
.where( .where(

View file

@ -33,6 +33,7 @@ import {
snapshotsEqual, snapshotsEqual,
} from "./external-object-assignment.persistence.js"; } from "./external-object-assignment.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
export class ExternalObjectNewCircuitProjectCommandRepository export class ExternalObjectNewCircuitProjectCommandRepository
implements ExternalObjectNewCircuitProjectCommandStore implements ExternalObjectNewCircuitProjectCommandStore
@ -162,6 +163,9 @@ export class ExternalObjectNewCircuitProjectCommandRepository
objects, objects,
"External object changed during new-circuit assignment." "External object changed during new-circuit assignment."
); );
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [
expectedCircuit.id,
]);
const deleted = database.delete(circuits).where(and( const deleted = database.delete(circuits).where(and(
eq(circuits.id, expectedCircuit.id), eq(circuits.id, expectedCircuit.id),
eq(circuits.circuitListId, expectedCircuit.circuitListId) eq(circuits.circuitListId, expectedCircuit.circuitListId)

View file

@ -14,6 +14,7 @@ import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js"; import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js"; import { projects } from "../src/db/schema/projects.js";
import { import {
@ -611,6 +612,62 @@ describe("circuit device-row move project-command repository", () => {
} }
}); });
it("blocks undo from removing a generated circuit used as a feeder", () => {
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,
}]
);
const moved = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
const downstream = new DistributionBoardFixtureRepository(
fixture.context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-02");
fixture.context.db
.update(distributionBoards)
.set({
feedingDistributionBoardId: fixture.circuitListId,
feedingCircuitId: targetCircuit.id,
})
.where(eq(distributionBoards.id, downstream.id))
.run();
assert.throws(
() => store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: moved.revision.changeSetId,
command: moved.inverse,
}),
/Speist UV-02 – erst Einspeisung ändern\./
);
assert.equal(getRow(fixture.context, "row-1")?.circuitId, targetCircuit.id);
assert.ok(
fixture.context.db.select().from(circuits)
.where(eq(circuits.id, targetCircuit.id)).get()
);
} finally {
fixture.context.close();
}
});
it("rejects deletion when the created target changed outside history", () => { it("rejects deletion when the created target changed outside history", () => {
const fixture = createTestDatabase(); const fixture = createTestDatabase();
try { try {

View file

@ -16,6 +16,7 @@ import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js"; import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js"; import { projects } from "../src/db/schema/projects.js";
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js"; import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
@ -228,6 +229,45 @@ describe("circuit-group subtree project command", () => {
} }
}); });
it("blocks deleting a group containing a feeding circuit", () => {
const context = createTestDatabase();
try {
const list = context.db.select().from(circuitLists).get()!;
const downstream = new DistributionBoardFixtureRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "Unterverteiler");
context.db
.update(distributionBoards)
.set({
feedingDistributionBoardId: list.distributionBoardId,
feedingCircuitId: "subtree-circuit",
})
.where(eq(distributionBoards.id, downstream.id))
.run();
const repository =
new CircuitGroupSubtreeProjectCommandRepository(context.db);
const groupId = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "subtree-circuit"))
.get()!.sectionId;
const snapshot = repository.capture("project-1", groupId);
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
}),
/Speist Unterverteiler – erst Einspeisung ändern\./
);
assert.deepEqual(repository.capture("project-1", groupId), snapshot);
} finally {
context.close();
}
});
it("rolls back a cascaded deletion when history persistence fails", () => { it("rolls back a cascaded deletion when history persistence fails", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {

View file

@ -14,6 +14,7 @@ import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js"; import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js"; import { projects } from "../src/db/schema/projects.js";
@ -442,6 +443,48 @@ describe("circuit structure project-command repository", () => {
} }
}); });
it("blocks deleting a circuit that feeds a distribution board", () => {
const fixture = createTestDatabase();
try {
const downstream = new DistributionBoardFixtureRepository(
fixture.context.db
).createWithCircuitListAndDefaultSections("project-1", "UV Unterverteiler");
fixture.context.db
.update(distributionBoards)
.set({
boardNumber: "UV-02",
feedingDistributionBoardId: fixture.circuitListId,
feedingCircuitId: "circuit-existing",
})
.where(eq(distributionBoards.id, downstream.id))
.run();
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeleteProjectCommand(
"circuit-existing",
fixture.circuitListId
),
}),
/Speist UV-02 – erst Einspeisung ändern\./
);
assert.ok(getCircuit(fixture.context, "circuit-existing"));
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back insert and delete for late history failures", () => { it("rolls back insert and delete for late history failures", () => {
const fixture = createTestDatabase(); const fixture = createTestDatabase();
try { try {

View file

@ -219,6 +219,54 @@ describe("distribution-board structure project command", () => {
} }
}); });
it("blocks undo from removing an empty board used as a feeder", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(context.db);
const upstream = createDistributionBoardStructureSnapshot(
"project-1",
"NSHV"
);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createDistributionBoardInsertProjectCommand(upstream),
});
const downstream = createDistributionBoardStructureSnapshot(
"project-1",
"UV-01"
);
context.db.insert(distributionBoards).values({
...downstream.distributionBoard,
boardNumber: "UV-01",
feedingDistributionBoardId: upstream.distributionBoard.id,
}).run();
context.db.insert(circuitLists).values(downstream.circuitList).run();
context.db.insert(circuitSections).values(downstream.sections).run();
context.db.insert(distributionBoardComponents)
.values(downstream.components).run();
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
}),
/Speist UV-01 – erst Einspeisung ändern\./
);
assert.ok(
context.db.select().from(distributionBoards)
.where(eq(distributionBoards.id, upstream.distributionBoard.id)).get()
);
} finally {
context.close();
}
});
it("allows only supply types enabled in the project", () => { it("allows only supply types enabled in the project", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {

View file

@ -291,6 +291,43 @@ describe("distribution-board subtree project command", () => {
} }
}); });
it("blocks deleting a board subtree that feeds another board", () => {
const context = createTestDatabase();
try {
const upstream = createPopulatedBoard(context);
const downstream = new DistributionBoardFixtureRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "Unterverteiler");
context.db
.update(distributionBoards)
.set({
boardNumber: "UV-03",
feedingDistributionBoardId: upstream.id,
})
.where(eq(distributionBoards.id, downstream.id))
.run();
const repository =
new DistributionBoardSubtreeProjectCommandRepository(context.db);
const snapshot = repository.capture("project-1", upstream.id);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardDeleteSubtreeProjectCommand(snapshot),
}),
/Speist UV-03 – erst Einspeisung ändern\./
);
assert.deepEqual(repository.capture("project-1", upstream.id), snapshot);
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
} finally {
context.close();
}
});
it("rolls back a cascaded deletion when history persistence fails", () => { it("rolls back a cascaded deletion when history persistence fails", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {

View file

@ -533,6 +533,61 @@ describe("external object new-circuit project command", () => {
} }
}); });
it("blocks history removal when the generated circuit became a feeder", () => {
const context = createFixture(null);
try {
const object = objectSnapshot(null);
const circuit = newExternalCircuit(context.db);
const command = createExternalObjectNewCircuitProjectCommand(
externalObjectAssignToNewCircuitCommandType,
{
circuit,
objects: [{
expected: object,
target: { ...object, circuitDeviceRowId: "row-new" },
}],
confirmedConflictObjectIds: [],
}
);
const repository = new ExternalObjectNewCircuitProjectCommandRepository(context.db);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
context.db.insert(distributionBoards).values({
id: "board-2",
projectId: "project-1",
name: "Unterverteiler",
boardNumber: "UV-02",
feedingDistributionBoardId: "board-1",
feedingCircuitId: circuit.id,
}).run();
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
}),
/Speist UV-02 – erst Einspeisung ändern\./
);
assert.ok(
context.db.select().from(circuits)
.where(eq(circuits.id, circuit.id)).get()
);
assert.equal(
context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId,
"row-new"
);
} finally {
context.close();
}
});
it("rolls back the complete circuit subtree when history persistence fails", () => { it("rolls back the complete circuit subtree when history persistence fails", () => {
const context = createFixture(null); const context = createFixture(null);
try { try {