From 0fd564a9300eeb4cba7f8f4d18c969f14b9c3e00 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 31 Jul 2026 11:57:10 +0200 Subject: [PATCH] Persist complete distribution board subtrees --- ...oard-subtree-project-command.repository.ts | 432 ++++++++++++++++++ ...ion-board-subtree-project-command.store.ts | 27 ++ .../services/project-command.service.ts | 22 + .../composition/project-command-stores.ts | 4 + tests/distribution-board-component.test.ts | 1 + ...subtree-project-command.repository.test.ts | 265 +++++++++++ tests/project-command.service.test.ts | 36 ++ ...t-state-restore-command.repository.test.ts | 2 + 8 files changed, 789 insertions(+) create mode 100644 src/db/repositories/distribution-board-subtree-project-command.repository.ts create mode 100644 src/domain/ports/distribution-board-subtree-project-command.store.ts create mode 100644 tests/distribution-board-subtree-project-command.repository.test.ts diff --git a/src/db/repositories/distribution-board-subtree-project-command.repository.ts b/src/db/repositories/distribution-board-subtree-project-command.repository.ts new file mode 100644 index 0000000..0fab26d --- /dev/null +++ b/src/db/repositories/distribution-board-subtree-project-command.repository.ts @@ -0,0 +1,432 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import { + assertDistributionBoardDeleteSubtreeProjectCommand, + assertDistributionBoardInsertSubtreeProjectCommand, + createDistributionBoardDeleteSubtreeProjectCommand, + createDistributionBoardInsertSubtreeProjectCommand, + distributionBoardDeleteSubtreeCommandType, + distributionBoardInsertSubtreeCommandType, + type DistributionBoardSubtreeProjectCommand, +} from "../../domain/models/distribution-board-subtree-project-command.model.js"; +import { + assertDistributionBoardSubtreeSnapshot, + type DistributionBoardSubtreeSnapshot, +} from "../../domain/models/distribution-board-subtree-snapshot.model.js"; +import type { + DistributionBoardSubtreeProjectCommandStore, + ExecuteDistributionBoardSubtreeCommandInput, +} from "../../domain/ports/distribution-board-subtree-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +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 { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; +import { distributionBoardComponents } from "../schema/distribution-board-components.js"; +import { distributionBoards } from "../schema/distribution-boards.js"; +import { floors } from "../schema/floors.js"; +import { projectDevices } from "../schema/project-devices.js"; +import { projects } from "../schema/projects.js"; +import { rooms } from "../schema/rooms.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; + +export class DistributionBoardSubtreeProjectCommandRepository + implements DistributionBoardSubtreeProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + capture(projectId: string, distributionBoardId: string) { + return this.captureFrom( + this.database, + projectId, + distributionBoardId + ); + } + + execute(input: ExecuteDistributionBoardSubtreeCommandInput) { + if (input.command.type === distributionBoardInsertSubtreeCommandType) { + assertDistributionBoardInsertSubtreeProjectCommand(input.command); + } else { + assertDistributionBoardDeleteSubtreeProjectCommand(input.command); + } + return executeProjectCommandTransaction( + this.database, + input, + (tx) => this.applyCommand(tx, input.projectId, input.command) + ); + } + + private applyCommand( + database: AppDatabase, + projectId: string, + command: DistributionBoardSubtreeProjectCommand + ) { + if (command.type === distributionBoardInsertSubtreeCommandType) { + this.insert(database, projectId, command.payload.snapshot); + return createDistributionBoardDeleteSubtreeProjectCommand( + command.payload.snapshot + ); + } + if (command.type === distributionBoardDeleteSubtreeCommandType) { + const current = this.captureFrom( + database, + projectId, + command.payload.snapshot.distributionBoard.id + ); + if (!sameSnapshot(current, command.payload.snapshot)) { + throw new Error( + "Distribution-board subtree changed before deletion." + ); + } + const deleted = database + .delete(distributionBoards) + .where( + and( + eq( + distributionBoards.id, + current.distributionBoard.id + ), + eq(distributionBoards.projectId, projectId) + ) + ) + .run(); + if (deleted.changes !== 1) { + throw new Error( + "Distribution-board subtree could not be deleted." + ); + } + return createDistributionBoardInsertSubtreeProjectCommand( + command.payload.snapshot + ); + } + throw new Error("Unsupported distribution-board subtree command."); + } + + private captureFrom( + database: AppDatabase, + projectId: string, + distributionBoardId: string + ): DistributionBoardSubtreeSnapshot { + const distributionBoard = database + .select() + .from(distributionBoards) + .where( + and( + eq(distributionBoards.id, distributionBoardId), + eq(distributionBoards.projectId, projectId) + ) + ) + .get(); + if (!distributionBoard) { + throw new Error("Distribution board not found."); + } + const circuitList = database + .select() + .from(circuitLists) + .where( + and( + eq(circuitLists.distributionBoardId, distributionBoard.id), + eq(circuitLists.projectId, projectId) + ) + ) + .get(); + if (!circuitList) { + throw new Error("Distribution-board circuit list not found."); + } + const sections = database + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, circuitList.id)) + .orderBy(asc(circuitSections.sortOrder), asc(circuitSections.id)) + .all(); + const components = database + .select() + .from(distributionBoardComponents) + .where(eq(distributionBoardComponents.circuitListId, circuitList.id)) + .orderBy( + asc(distributionBoardComponents.sortOrder), + asc(distributionBoardComponents.id) + ) + .all() + .map((component) => ({ + component, + protectionDevice: + database + .select() + .from(distributionBoardComponentProtectionDevices) + .where( + eq( + distributionBoardComponentProtectionDevices.componentId, + component.id + ) + ) + .get() ?? null, + })); + const circuitSnapshots = database + .select() + .from(circuits) + .where(eq(circuits.circuitListId, circuitList.id)) + .orderBy(asc(circuits.sortOrder), asc(circuits.id)) + .all() + .map((circuit) => { + const { isReserve, ...values } = circuit; + return { + ...values, + isReserve: Boolean(isReserve), + deviceRows: database + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, circuit.id)) + .orderBy( + asc(circuitDeviceRows.sortOrder), + asc(circuitDeviceRows.id) + ) + .all(), + protectionDevice: + database + .select() + .from(circuitProtectionDevices) + .where(eq(circuitProtectionDevices.circuitId, circuit.id)) + .get() ?? null, + }; + }); + const snapshot: DistributionBoardSubtreeSnapshot = { + distributionBoard, + circuitList, + sections, + components, + circuits: circuitSnapshots, + }; + assertDistributionBoardSubtreeSnapshot(snapshot); + return snapshot; + } + + private insert( + database: AppDatabase, + projectId: string, + snapshot: DistributionBoardSubtreeSnapshot + ) { + assertDistributionBoardSubtreeSnapshot(snapshot); + if ( + snapshot.distributionBoard.projectId !== projectId || + snapshot.circuitList.projectId !== projectId + ) { + throw new Error( + "Distribution-board subtree belongs to another project." + ); + } + this.assertProjectReferences(database, projectId, snapshot); + this.assertIdsAvailable(database, snapshot); + + database + .insert(distributionBoards) + .values(snapshot.distributionBoard) + .run(); + database.insert(circuitLists).values(snapshot.circuitList).run(); + if (snapshot.sections.length > 0) { + database.insert(circuitSections).values(snapshot.sections).run(); + } + for (const entry of snapshot.components) { + database + .insert(distributionBoardComponents) + .values(entry.component) + .run(); + if (entry.protectionDevice !== null) { + database + .insert(distributionBoardComponentProtectionDevices) + .values(entry.protectionDevice) + .run(); + } + } + for (const circuit of snapshot.circuits) { + const { deviceRows, protectionDevice, isReserve, ...values } = + circuit; + database + .insert(circuits) + .values({ ...values, isReserve: isReserve ? 1 : 0 }) + .run(); + if (protectionDevice !== null && protectionDevice !== undefined) { + database + .insert(circuitProtectionDevices) + .values(protectionDevice) + .run(); + } + if (deviceRows.length > 0) { + database.insert(circuitDeviceRows).values(deviceRows).run(); + } + } + } + + private assertProjectReferences( + database: AppDatabase, + projectId: string, + snapshot: DistributionBoardSubtreeSnapshot + ) { + const project = database + .select({ + enabledSupplyTypes: + projects.enabledDistributionBoardSupplyTypes, + }) + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (!project) { + throw new Error("Project not found."); + } + if ( + snapshot.distributionBoard.supplyType !== null && + !project.enabledSupplyTypes.includes( + snapshot.distributionBoard.supplyType + ) + ) { + throw new Error( + "Distribution-board subtree uses a disabled supply type." + ); + } + if (snapshot.distributionBoard.floorId !== null) { + const floor = database + .select({ projectId: floors.projectId }) + .from(floors) + .where(eq(floors.id, snapshot.distributionBoard.floorId)) + .get(); + if (!floor || floor.projectId !== projectId) { + throw new Error( + "Distribution-board floor does not belong to project." + ); + } + } + const linkedProjectDeviceIds = uniqueStrings( + snapshot.circuits.flatMap((circuit) => + circuit.deviceRows.flatMap((row) => + row.linkedProjectDeviceId === null + ? [] + : [row.linkedProjectDeviceId] + ) + ) + ); + if (linkedProjectDeviceIds.length > 0) { + const references = database + .select({ id: projectDevices.id, projectId: projectDevices.projectId }) + .from(projectDevices) + .where(inArray(projectDevices.id, linkedProjectDeviceIds)) + .all(); + if ( + references.length !== linkedProjectDeviceIds.length || + references.some((reference) => reference.projectId !== projectId) + ) { + throw new Error( + "Distribution-board project-device reference is invalid." + ); + } + } + const roomIds = uniqueStrings( + snapshot.circuits.flatMap((circuit) => + circuit.deviceRows.flatMap((row) => + row.roomId === null ? [] : [row.roomId] + ) + ) + ); + if (roomIds.length > 0) { + const references = database + .select({ id: rooms.id, projectId: rooms.projectId }) + .from(rooms) + .where(inArray(rooms.id, roomIds)) + .all(); + if ( + references.length !== roomIds.length || + references.some((reference) => reference.projectId !== projectId) + ) { + throw new Error("Distribution-board room reference is invalid."); + } + } + } + + private assertIdsAvailable( + database: AppDatabase, + snapshot: DistributionBoardSubtreeSnapshot + ) { + const sectionIds = snapshot.sections.map((section) => section.id); + const componentIds = snapshot.components.map( + ({ component }) => component.id + ); + const circuitIds = snapshot.circuits.map((circuit) => circuit.id); + const deviceRowIds = snapshot.circuits.flatMap((circuit) => + circuit.deviceRows.map((row) => row.id) + ); + const conflicts = [ + database + .select({ id: distributionBoards.id }) + .from(distributionBoards) + .where(eq(distributionBoards.id, snapshot.distributionBoard.id)) + .get(), + database + .select({ id: circuitLists.id }) + .from(circuitLists) + .where(eq(circuitLists.id, snapshot.circuitList.id)) + .get(), + sectionIds.length === 0 + ? undefined + : database + .select({ id: circuitSections.id }) + .from(circuitSections) + .where(inArray(circuitSections.id, sectionIds)) + .limit(1) + .get(), + componentIds.length === 0 + ? undefined + : database + .select({ id: distributionBoardComponents.id }) + .from(distributionBoardComponents) + .where(inArray(distributionBoardComponents.id, componentIds)) + .limit(1) + .get(), + circuitIds.length === 0 + ? undefined + : database + .select({ id: circuits.id }) + .from(circuits) + .where(inArray(circuits.id, circuitIds)) + .limit(1) + .get(), + deviceRowIds.length === 0 + ? undefined + : database + .select({ id: circuitDeviceRows.id }) + .from(circuitDeviceRows) + .where(inArray(circuitDeviceRows.id, deviceRowIds)) + .limit(1) + .get(), + ]; + if (conflicts.some(Boolean)) { + throw new Error("Distribution-board subtree id already exists."); + } + } +} + +function uniqueStrings(values: string[]) { + return [...new Set(values)]; +} + +function sameSnapshot( + left: DistributionBoardSubtreeSnapshot, + right: DistributionBoardSubtreeSnapshot +) { + return canonicalJson(left) === canonicalJson(right); +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (value !== null && typeof value === "object") { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map( + ([key, child]) => + `${JSON.stringify(key)}:${canonicalJson(child)}` + ) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} diff --git a/src/domain/ports/distribution-board-subtree-project-command.store.ts b/src/domain/ports/distribution-board-subtree-project-command.store.ts new file mode 100644 index 0000000..53715dd --- /dev/null +++ b/src/domain/ports/distribution-board-subtree-project-command.store.ts @@ -0,0 +1,27 @@ +import type { DistributionBoardSubtreeProjectCommand } from "../models/distribution-board-subtree-project-command.model.js"; +import type { DistributionBoardSubtreeSnapshot } from "../models/distribution-board-subtree-snapshot.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteDistributionBoardSubtreeCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: DistributionBoardSubtreeProjectCommand; +} + +export interface DistributionBoardSubtreeProjectCommandStore { + capture( + projectId: string, + distributionBoardId: string + ): DistributionBoardSubtreeSnapshot; + execute(input: ExecuteDistributionBoardSubtreeCommandInput): { + revision: AppendedProjectRevision; + inverse: DistributionBoardSubtreeProjectCommand; + }; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 8d0fc62..2f2ba16 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -59,6 +59,12 @@ import { distributionBoardDeleteCommandType, distributionBoardInsertCommandType, } from "../models/distribution-board-structure-project-command.model.js"; +import { + assertDistributionBoardDeleteSubtreeProjectCommand, + assertDistributionBoardInsertSubtreeProjectCommand, + distributionBoardDeleteSubtreeCommandType, + distributionBoardInsertSubtreeCommandType, +} from "../models/distribution-board-subtree-project-command.model.js"; import { assertSerializedProjectCommand, type SerializedProjectCommand, @@ -108,6 +114,7 @@ import type { CircuitGroupRenumberProjectCommandStore } from "../ports/circuit-g import type { CircuitGroupMoveProjectCommandStore } from "../ports/circuit-group-move-project-command.store.js"; import type { CircuitGroupSubtreeProjectCommandStore } from "../ports/circuit-group-subtree-project-command.store.js"; import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js"; +import type { DistributionBoardSubtreeProjectCommandStore } from "../ports/distribution-board-subtree-project-command.store.js"; import type { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js"; import type { ExecuteProjectHistoryCommandInput, @@ -169,6 +176,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore, private readonly circuitStructureStore: CircuitStructureProjectCommandStore, private readonly distributionBoardStructureStore: DistributionBoardStructureProjectCommandStore, + private readonly distributionBoardSubtreeStore: DistributionBoardSubtreeProjectCommandStore, private readonly projectLocationStructureStore: ProjectLocationStructureProjectCommandStore, private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore, private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore, @@ -337,6 +345,20 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case distributionBoardInsertSubtreeCommandType: { + assertDistributionBoardInsertSubtreeProjectCommand(input.command); + return this.distributionBoardSubtreeStore.execute({ + ...input, + command: input.command, + }).revision; + } + case distributionBoardDeleteSubtreeCommandType: { + assertDistributionBoardDeleteSubtreeProjectCommand(input.command); + return this.distributionBoardSubtreeStore.execute({ + ...input, + command: input.command, + }).revision; + } case distributionBoardUpdateCommandType: { assertDistributionBoardUpdateProjectCommand(input.command); return this.distributionBoardStructureStore.executeUpdate({ diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 51a0b0e..ef51bb9 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -12,6 +12,7 @@ import { CircuitGroupRenumberProjectCommandRepository } from "../../db/repositor import { CircuitGroupMoveProjectCommandRepository } from "../../db/repositories/circuit-group-move-project-command.repository.js"; import { CircuitGroupSubtreeProjectCommandRepository } from "../../db/repositories/circuit-group-subtree-project-command.repository.js"; import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js"; +import { DistributionBoardSubtreeProjectCommandRepository } from "../../db/repositories/distribution-board-subtree-project-command.repository.js"; import { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js"; import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectLocationStructureProjectCommandRepository } from "../../db/repositories/project-location-structure-project-command.repository.js"; @@ -43,6 +44,8 @@ export const circuitGroupSubtreeProjectCommandStore = new CircuitGroupSubtreeProjectCommandRepository(db); export const distributionBoardStructureProjectCommandStore = new DistributionBoardStructureProjectCommandRepository(db); +export const distributionBoardSubtreeProjectCommandStore = + new DistributionBoardSubtreeProjectCommandRepository(db); export const distributionBoardComponentStructureProjectCommandStore = new DistributionBoardComponentStructureProjectCommandRepository(db); export const projectLocationStructureProjectCommandStore = @@ -69,6 +72,7 @@ export const projectCommandService = new ProjectCommandService( circuitDeviceRowMoveProjectCommandStore, circuitStructureProjectCommandStore, distributionBoardStructureProjectCommandStore, + distributionBoardSubtreeProjectCommandStore, projectLocationStructureProjectCommandStore, circuitSectionReorderProjectCommandStore, circuitSectionRenumberProjectCommandStore, diff --git a/tests/distribution-board-component.test.ts b/tests/distribution-board-component.test.ts index 471d11a..e406fd7 100644 --- a/tests/distribution-board-component.test.ts +++ b/tests/distribution-board-component.test.ts @@ -16,6 +16,7 @@ import { } from "../src/frontend/utils/distribution-board-component-editing.js"; import "./distribution-board-component-structure-project-command.repository.test.js"; import "./distribution-board-subtree.test.js"; +import "./distribution-board-subtree-project-command.repository.test.js"; describe("distribution board component catalog", () => { it("defines the agreed component roles and placement zones", () => { diff --git a/tests/distribution-board-subtree-project-command.repository.test.ts b/tests/distribution-board-subtree-project-command.repository.test.ts new file mode 100644 index 0000000..0266e4f --- /dev/null +++ b/tests/distribution-board-subtree-project-command.repository.test.ts @@ -0,0 +1,265 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { DistributionBoardSubtreeProjectCommandRepository } from "../src/db/repositories/distribution-board-subtree-project-command.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.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 { projects } from "../src/db/schema/projects.js"; +import { + createDistributionBoardDeleteSubtreeProjectCommand, + createDistributionBoardInsertSubtreeProjectCommand, +} from "../src/domain/models/distribution-board-subtree-project-command.model.js"; +import { cloneDistributionBoardSubtree } from "../src/domain/models/distribution-board-subtree-snapshot.model.js"; +import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db + .insert(projects) + .values({ id: "project-1", name: "Test project" }) + .run(); + return context; +} + +function createPopulatedBoard(context: DatabaseContext) { + const board = new DistributionBoardFixtureRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-1", "UV 1"); + const section = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, board.id)) + .get(); + assert.ok(section); + context.db + .insert(distributionBoardComponents) + .values({ + id: "group-rcd", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1Q1.0", + name: "Gruppen-FI", + role: "group_residual_current_protection", + placement: "group", + sortOrder: 30, + }) + .run(); + context.db + .insert(distributionBoardComponentProtectionDevices) + .values({ + componentId: "group-rcd", + type: "FI", + ratedCurrentA: 40, + rcdType: "A", + ratedResidualCurrentMa: 30, + }) + .run(); + context.db + .insert(circuits) + .values({ + id: "circuit-1", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F1.1", + displayName: "Licht", + sortOrder: 10, + voltage: 230, + }) + .run(); + context.db + .insert(circuitProtectionDevices) + .values({ + circuitId: "circuit-1", + type: "LS", + ratedCurrentA: 10, + tripCharacteristic: "B", + }) + .run(); + context.db + .insert(circuitDeviceRows) + .values({ + id: "row-1", + circuitId: "circuit-1", + sortOrder: 10, + name: "Leuchte", + displayName: "Leuchte", + phaseType: "single_phase", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }) + .run(); + return board; +} + +describe("distribution-board subtree project command", () => { + it("copies and deletes populated boards through persistent undo and redo", () => { + const context = createTestDatabase(); + try { + const source = createPopulatedBoard(context); + const repository = + new DistributionBoardSubtreeProjectCommandRepository(context.db); + const history = new ProjectHistoryRepository(context.db); + const sourceSnapshot = repository.capture("project-1", source.id); + const clone = cloneDistributionBoardSubtree( + sourceSnapshot, + "UV 1 Kopie" + ); + + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + description: "Verteilung kopieren", + command: createDistributionBoardInsertSubtreeProjectCommand(clone), + }); + assert.deepEqual( + repository.capture("project-1", clone.distributionBoard.id), + clone + ); + + const undoInsert = history.getNextCommand("project-1", "undo"); + assert.ok(undoInsert); + const removedClone = repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: undoInsert.changeSetId, + command: inserted.inverse, + }); + assert.throws( + () => repository.capture("project-1", clone.distributionBoard.id), + /not found/ + ); + + const redoInsert = history.getNextCommand("project-1", "redo"); + assert.ok(redoInsert); + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: redoInsert.changeSetId, + command: removedClone.inverse, + }); + assert.deepEqual( + repository.capture("project-1", clone.distributionBoard.id), + clone + ); + + const deleted = repository.execute({ + projectId: "project-1", + expectedRevision: 3, + source: "user", + description: "Verteilung löschen", + command: + createDistributionBoardDeleteSubtreeProjectCommand(sourceSnapshot), + }); + assert.throws( + () => repository.capture("project-1", source.id), + /not found/ + ); + const undoDelete = history.getNextCommand("project-1", "undo"); + assert.ok(undoDelete); + repository.execute({ + projectId: "project-1", + expectedRevision: 4, + source: "undo", + historyTargetChangeSetId: undoDelete.changeSetId, + command: deleted.inverse, + }); + assert.deepEqual( + repository.capture("project-1", source.id), + sourceSnapshot + ); + } finally { + context.close(); + } + }); + + it("rejects changed deletion snapshots without partial writes", () => { + const context = createTestDatabase(); + try { + const board = createPopulatedBoard(context); + const repository = + new DistributionBoardSubtreeProjectCommandRepository(context.db); + const snapshot = repository.capture("project-1", board.id); + context.db + .update(distributionBoards) + .set({ name: "Extern geändert" }) + .where(eq(distributionBoards.id, board.id)) + .run(); + + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createDistributionBoardDeleteSubtreeProjectCommand(snapshot), + }), + /changed before deletion/ + ); + assert.equal( + context.db + .select({ name: distributionBoards.name }) + .from(distributionBoards) + .where(eq(distributionBoards.id, board.id)) + .get()?.name, + "Extern geändert" + ); + assert.equal(context.db.select().from(projectRevisions).all().length, 0); + } finally { + context.close(); + } + }); + + it("rolls back a cascaded deletion when history persistence fails", () => { + const context = createTestDatabase(); + try { + const board = createPopulatedBoard(context); + const repository = + new DistributionBoardSubtreeProjectCommandRepository(context.db); + const snapshot = repository.capture("project-1", board.id); + context.sqlite.exec(` + CREATE TRIGGER fail_distribution_board_subtree_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced distribution-board subtree history failure'); + END; + `); + + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createDistributionBoardDeleteSubtreeProjectCommand(snapshot), + }), + /forced distribution-board subtree history failure/ + ); + assert.deepEqual(repository.capture("project-1", board.id), snapshot); + assert.equal(context.db.select().from(projectRevisions).all().length, 0); + } finally { + context.close(); + } + }); +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 33a9821..3a119e0 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -21,6 +21,7 @@ import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js"; +import { DistributionBoardSubtreeProjectCommandRepository } from "../src/db/repositories/distribution-board-subtree-project-command.repository.js"; import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js"; @@ -34,6 +35,7 @@ import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-de import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; +import { distributionBoards } from "../src/db/schema/distribution-boards.js"; import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; @@ -68,6 +70,7 @@ import { createDistributionBoardInsertProjectCommand, createDistributionBoardStructureSnapshot, } from "../src/domain/models/distribution-board-structure-project-command.model.js"; +import { createDistributionBoardDeleteSubtreeProjectCommand } from "../src/domain/models/distribution-board-subtree-project-command.model.js"; import { createDistributionBoardComponentInsertProjectCommand, createDistributionBoardComponentUpdateProjectCommand, @@ -137,6 +140,7 @@ function createService(context: DatabaseContext) { new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db), new DistributionBoardStructureProjectCommandRepository(context.db), + new DistributionBoardSubtreeProjectCommandRepository(context.db), new ProjectLocationStructureProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db), @@ -174,6 +178,38 @@ function getRowQuantity(context: DatabaseContext) { } describe("project command service", () => { + it("dispatches populated distribution-board subtree deletion through history", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const repository = + new DistributionBoardSubtreeProjectCommandRepository(context.db); + const board = context.db.select().from(distributionBoards).get(); + assert.ok(board); + const snapshot = repository.capture("project-1", board.id); + + service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: + createDistributionBoardDeleteSubtreeProjectCommand(snapshot), + }); + assert.equal( + context.db + .select() + .from(distributionBoards) + .where(eq(distributionBoards.id, board.id)) + .get(), + undefined + ); + + service.undo({ projectId: "project-1", expectedRevision: 1 }); + assert.deepEqual(repository.capture("project-1", board.id), snapshot); + } finally { + context.close(); + } + }); + it("dispatches circuit-protection updates through project history", () => { const context = createTestDatabase(); try { diff --git a/tests/project-state-restore-command.repository.test.ts b/tests/project-state-restore-command.repository.test.ts index 5bf1ac6..b44d3c0 100644 --- a/tests/project-state-restore-command.repository.test.ts +++ b/tests/project-state-restore-command.repository.test.ts @@ -21,6 +21,7 @@ import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js"; +import { DistributionBoardSubtreeProjectCommandRepository } from "../src/db/repositories/distribution-board-subtree-project-command.repository.js"; import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js"; import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js"; @@ -189,6 +190,7 @@ function createService(context: DatabaseContext) { new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db), new DistributionBoardStructureProjectCommandRepository(context.db), + new DistributionBoardSubtreeProjectCommandRepository(context.db), new ProjectLocationStructureProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db),