Add circuit history commands

This commit is contained in:
2026-07-23 22:18:36 +02:00
parent bd5f4f925f
commit ca13efbfcb
16 changed files with 1402 additions and 85 deletions
+2 -2
View File
@@ -224,8 +224,8 @@ Session-local UI undo/redo exists for structural and destructive operations.
The server already persists immutable revisions and project-wide undo/redo The server already persists immutable revisions and project-wide undo/redo
eligibility for the first commands. Public command, undo and redo endpoints eligibility for the first commands. Public command, undo and redo endpoints
exist for Circuit and CircuitDeviceRow field updates as well as exist for Circuit and CircuitDeviceRow field updates as well as
CircuitDeviceRow insertion/deletion. Complete command coverage and the UI Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and
cutover remain future work. the UI cutover remain future work.
Required operations: Required operations:
+9 -7
View File
@@ -23,13 +23,15 @@ concurrency checks.
- executes the eligible inverse or forward command as a new auditable - executes the eligible inverse or forward command as a new auditable
revision revision
The public dispatcher currently supports `circuit.update`, The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit-device-row.update`, `circuit-device-row.insert` and `circuit.delete`, `circuit-device-row.update`,
`circuit-device-row.delete`, all with schema version `1`. Other command types `circuit-device-row.insert` and `circuit-device-row.delete`, all with schema
are rejected. Insert commands contain the complete row with its stable id; version `1`. Other command types are rejected. Insert commands contain the
delete commands identify the row and its expected circuit. This makes deletion complete entity or circuit block with stable ids; delete commands include the
undoable without generating a replacement identity. The editor does not expected parent identity. Circuit snapshots contain zero, one or multiple
consume these endpoints yet. complete device rows. This makes deletion undoable without generating
replacement identities or renumbering circuits. The editor does not consume
these endpoints yet.
Example: Example:
+7 -4
View File
@@ -82,10 +82,13 @@ die unterstützten Typen `circuit.update` und `circuit-device-row.update` über
`circuit-device-row.insert` und `circuit-device-row.delete` als atomare `circuit-device-row.insert` und `circuit-device-row.delete` als atomare
Strukturkommandos vorhanden. Beim Löschen wird die vollständige Zeile im Strukturkommandos vorhanden. Beim Löschen wird die vollständige Zeile im
inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte
wiederherstellt. Das Grid verwendet diese Endpunkte noch nicht; seine sichtbare wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen
Historie bleibt daher sitzungslokal. Die übrigen Struktur-, Synchronisierungs- Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
und Löschoperationen müssen vor der Frontend-Umstellung ebenfalls als Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
persistente Commands modelliert werden. Betriebsmittelkennzeichen. Das Grid verwendet diese Endpunkte noch nicht; seine
sichtbare Historie bleibt daher sitzungslokal. Verschiebe-, Sortier-,
Neunummerierungs- und Synchronisierungsoperationen müssen vor der
Frontend-Umstellung ebenfalls als persistente Commands modelliert werden.
## Projektgeräte ## Projektgeräte
@@ -176,12 +176,15 @@ Completed foundation:
- `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable - `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable
row ids, complete deleted-row snapshots and circuit reserve state through row ids, complete deleted-row snapshots and circuit reserve state through
atomic insert/delete, revision and stack transitions atomic insert/delete, revision and stack transitions
- `circuit.insert` and `circuit.delete` persist a complete circuit block with
zero, one or multiple device rows; undo restores all ids and fields without
renumbering
Remaining constraints before completing persistent history: Remaining constraints before completing persistent history:
- extend the concrete project-scoped command union and executors beyond the - extend the concrete project-scoped command union and executors beyond the
Circuit field and CircuitDeviceRow field/insert/delete slices; the generic Circuit field/insert/delete and CircuitDeviceRow field/insert/delete slices;
versioned command envelope is complete the generic versioned command envelope is complete
- route the remaining structural, synchronization and destructive domain - route the remaining structural, synchronization and destructive domain
writes through the shared command, revision and stack transaction boundary writes through the shared command, revision and stack transaction boundary
- replace the session-local frontend command stack only after server-side - replace the session-local frontend command stack only after server-side
@@ -393,6 +393,8 @@ Implemented foundation:
- typed CircuitDeviceRow insert/delete commands preserve complete row snapshots - typed CircuitDeviceRow insert/delete commands preserve complete row snapshots
and stable ids; row mutation, circuit reserve state, revision and history and stable ids; row mutation, circuit reserve state, revision and history
stack transition commit or roll back together stack transition commit or roll back together
- typed Circuit insert/delete commands treat the circuit and all device rows as
one block, preserve every stable id and never renumber neighbouring circuits
- revision metadata, change-set payloads and the project counter are committed - revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction in one SQLite transaction
- a stale expected revision produces no history writes - a stale expected revision produces no history writes
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
@@ -17,8 +17,10 @@ import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js"; import {
import { rooms } from "../schema/rooms.js"; assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js"; import { appendProjectRevision } from "./project-revision.persistence.js";
@@ -93,7 +95,7 @@ export class CircuitDeviceRowStructureProjectCommandRepository
); );
} }
this.assertCircuitInProject(database, projectId, row.circuitId); this.assertCircuitInProject(database, projectId, row.circuitId);
this.assertReferencesInProject(database, projectId, row); assertCircuitDeviceRowReferencesInProject(database, projectId, row);
const existing = database const existing = database
.select({ id: circuitDeviceRows.id }) .select({ id: circuitDeviceRows.id })
@@ -194,69 +196,4 @@ export class CircuitDeviceRowStructureProjectCommandRepository
throw new Error("Circuit does not belong to project."); throw new Error("Circuit does not belong to project.");
} }
} }
private assertReferencesInProject(
database: AppDatabase,
projectId: string,
row: CircuitDeviceRowSnapshot
) {
if (row.linkedProjectDeviceId !== null) {
const device = database
.select({ id: projectDevices.id })
.from(projectDevices)
.where(
and(
eq(projectDevices.id, row.linkedProjectDeviceId),
eq(projectDevices.projectId, projectId)
)
)
.get();
if (!device) {
throw new Error("Invalid linked project device id.");
}
}
if (row.roomId !== null) {
const room = database
.select({ id: rooms.id })
.from(rooms)
.where(
and(
eq(rooms.id, row.roomId),
eq(rooms.projectId, projectId)
)
)
.get();
if (!room) {
throw new Error("Invalid room id.");
}
}
}
}
function toCircuitDeviceRowSnapshot(
row: typeof circuitDeviceRows.$inferSelect
): CircuitDeviceRowSnapshot {
return {
id: row.id,
circuitId: row.circuitId,
linkedProjectDeviceId: row.linkedProjectDeviceId,
legacyConsumerId: row.legacyConsumerId,
sortOrder: row.sortOrder,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType,
connectionKind: row.connectionKind,
costGroup: row.costGroup,
category: row.category,
level: row.level,
roomId: row.roomId,
roomNumberSnapshot: row.roomNumberSnapshot,
roomNameSnapshot: row.roomNameSnapshot,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
remark: row.remark,
overriddenFields: row.overriddenFields,
};
} }
@@ -0,0 +1,71 @@
import { and, eq } from "drizzle-orm";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { projectDevices } from "../schema/project-devices.js";
import { rooms } from "../schema/rooms.js";
export function assertCircuitDeviceRowReferencesInProject(
database: AppDatabase,
projectId: string,
row: CircuitDeviceRowSnapshot
) {
if (row.linkedProjectDeviceId !== null) {
const device = database
.select({ id: projectDevices.id })
.from(projectDevices)
.where(
and(
eq(projectDevices.id, row.linkedProjectDeviceId),
eq(projectDevices.projectId, projectId)
)
)
.get();
if (!device) {
throw new Error("Invalid linked project device id.");
}
}
if (row.roomId !== null) {
const room = database
.select({ id: rooms.id })
.from(rooms)
.where(
and(
eq(rooms.id, row.roomId),
eq(rooms.projectId, projectId)
)
)
.get();
if (!room) {
throw new Error("Invalid room id.");
}
}
}
export function toCircuitDeviceRowSnapshot(
row: typeof circuitDeviceRows.$inferSelect
): CircuitDeviceRowSnapshot {
return {
id: row.id,
circuitId: row.circuitId,
linkedProjectDeviceId: row.linkedProjectDeviceId,
legacyConsumerId: row.legacyConsumerId,
sortOrder: row.sortOrder,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType,
connectionKind: row.connectionKind,
costGroup: row.costGroup,
category: row.category,
level: row.level,
roomId: row.roomId,
roomNumberSnapshot: row.roomNumberSnapshot,
roomNameSnapshot: row.roomNameSnapshot,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
remark: row.remark,
overriddenFields: row.overriddenFields,
};
}
@@ -0,0 +1,291 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import {
assertCircuitDeleteProjectCommand,
assertCircuitInsertProjectCommand,
circuitDeleteCommandType,
circuitInsertCommandType,
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
type CircuitSnapshot,
type CircuitStructureProjectCommand,
} from "../../domain/models/circuit-structure-project-command.model.js";
import type {
CircuitStructureProjectCommandStore,
ExecuteCircuitStructureCommandInput,
} from "../../domain/ports/circuit-structure-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 { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import {
assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitStructureCommandInput) {
return this.database.transaction((tx) => {
const inverse = this.applyCommand(
tx,
input.projectId,
input.source,
input.command
);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
private applyCommand(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
command: CircuitStructureProjectCommand
): CircuitStructureProjectCommand {
if (command.type === circuitInsertCommandType) {
assertCircuitInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
command.payload.circuit
);
}
if (command.type === circuitDeleteCommandType) {
assertCircuitDeleteProjectCommand(command);
return this.delete(
database,
projectId,
command.payload.circuitId,
command.payload.expectedCircuitListId
);
}
throw new Error("Unsupported circuit structure command.");
}
private insert(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
snapshot: CircuitSnapshot
) {
this.assertCircuitLocation(database, projectId, snapshot);
const existingCircuit = database
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, snapshot.id))
.get();
if (existingCircuit) {
throw new Error("Circuit id already exists.");
}
const duplicateIdentifier = database
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, snapshot.circuitListId),
eq(
circuits.equipmentIdentifier,
snapshot.equipmentIdentifier
)
)
)
.get();
if (duplicateIdentifier) {
throw new Error(
"Duplicate equipmentIdentifier in circuit list."
);
}
if (snapshot.deviceRows.length > 0) {
const rowIds = snapshot.deviceRows.map((row) => row.id);
const existingRow = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds))
.limit(1)
.get();
if (existingRow) {
throw new Error("Circuit device-row id already exists.");
}
for (const row of snapshot.deviceRows) {
if (source === "user" && row.legacyConsumerId !== null) {
throw new Error(
"User commands cannot assign legacy consumer ids."
);
}
assertCircuitDeviceRowReferencesInProject(
database,
projectId,
row
);
}
}
database
.insert(circuits)
.values({
id: snapshot.id,
circuitListId: snapshot.circuitListId,
sectionId: snapshot.sectionId,
equipmentIdentifier: snapshot.equipmentIdentifier,
displayName: snapshot.displayName,
sortOrder: snapshot.sortOrder,
protectionType: snapshot.protectionType,
protectionRatedCurrent: snapshot.protectionRatedCurrent,
protectionCharacteristic: snapshot.protectionCharacteristic,
cableType: snapshot.cableType,
cableCrossSection: snapshot.cableCrossSection,
cableLength: snapshot.cableLength,
rcdAssignment: snapshot.rcdAssignment,
terminalDesignation: snapshot.terminalDesignation,
voltage: snapshot.voltage,
controlRequirement: snapshot.controlRequirement,
status: snapshot.status,
isReserve: snapshot.isReserve ? 1 : 0,
remark: snapshot.remark,
})
.run();
if (snapshot.deviceRows.length > 0) {
database.insert(circuitDeviceRows).values(snapshot.deviceRows).run();
}
return createCircuitDeleteProjectCommand(
snapshot.id,
snapshot.circuitListId
);
}
private delete(
database: AppDatabase,
projectId: string,
circuitId: string,
expectedCircuitListId: string
) {
const circuit = database
.select()
.from(circuits)
.where(eq(circuits.id, circuitId))
.get();
if (!circuit || circuit.circuitListId !== expectedCircuitListId) {
throw new Error("Circuit changed before command execution.");
}
const owningList = database
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, circuit.circuitListId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!owningList) {
throw new Error("Circuit does not belong to project.");
}
const rows = database
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuit.id))
.orderBy(
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
const inverse = createCircuitInsertProjectCommand({
id: circuit.id,
circuitListId: circuit.circuitListId,
sectionId: circuit.sectionId,
equipmentIdentifier: circuit.equipmentIdentifier,
displayName: circuit.displayName,
sortOrder: circuit.sortOrder,
protectionType: circuit.protectionType,
protectionRatedCurrent: circuit.protectionRatedCurrent,
protectionCharacteristic: circuit.protectionCharacteristic,
cableType: circuit.cableType,
cableCrossSection: circuit.cableCrossSection,
cableLength: circuit.cableLength,
rcdAssignment: circuit.rcdAssignment,
terminalDesignation: circuit.terminalDesignation,
voltage: circuit.voltage,
controlRequirement: circuit.controlRequirement,
status: circuit.status,
isReserve: Boolean(circuit.isReserve),
remark: circuit.remark,
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
});
const result = database
.delete(circuits)
.where(
and(
eq(circuits.id, circuitId),
eq(circuits.circuitListId, expectedCircuitListId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("Circuit could not be deleted.");
}
return inverse;
}
private assertCircuitLocation(
database: AppDatabase,
projectId: string,
snapshot: CircuitSnapshot
) {
const list = database
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, snapshot.circuitListId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!list) {
throw new Error("Circuit list does not belong to project.");
}
const section = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(
and(
eq(circuitSections.id, snapshot.sectionId),
eq(
circuitSections.circuitListId,
snapshot.circuitListId
)
)
)
.get();
if (!section) {
throw new Error("Section does not belong to circuit list.");
}
}
}
@@ -0,0 +1,220 @@
import {
assertCircuitDeviceRowInsertProjectCommand,
circuitDeviceRowInsertCommandType,
circuitDeviceRowStructureCommandSchemaVersion,
type CircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitInsertCommandType = "circuit.insert" as const;
export const circuitDeleteCommandType = "circuit.delete" as const;
export const circuitStructureCommandSchemaVersion = 1 as const;
export interface CircuitSnapshot {
id: string;
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string | null;
sortOrder: number;
protectionType: string | null;
protectionRatedCurrent: number | null;
protectionCharacteristic: string | null;
cableType: string | null;
cableCrossSection: string | null;
cableLength: number | null;
rcdAssignment: string | null;
terminalDesignation: string | null;
voltage: number | null;
controlRequirement: string | null;
status: string | null;
isReserve: boolean;
remark: string | null;
deviceRows: CircuitDeviceRowSnapshot[];
}
export interface CircuitInsertCommandPayload {
circuit: CircuitSnapshot;
}
export interface CircuitDeleteCommandPayload {
circuitId: string;
expectedCircuitListId: string;
}
export interface CircuitInsertProjectCommand
extends SerializedProjectCommand<CircuitInsertCommandPayload> {
schemaVersion: typeof circuitStructureCommandSchemaVersion;
type: typeof circuitInsertCommandType;
}
export interface CircuitDeleteProjectCommand
extends SerializedProjectCommand<CircuitDeleteCommandPayload> {
schemaVersion: typeof circuitStructureCommandSchemaVersion;
type: typeof circuitDeleteCommandType;
}
export type CircuitStructureProjectCommand =
| CircuitInsertProjectCommand
| CircuitDeleteProjectCommand;
export function createCircuitInsertProjectCommand(
circuit: CircuitSnapshot
): CircuitInsertProjectCommand {
const command: CircuitInsertProjectCommand = {
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitInsertCommandType,
payload: { circuit },
};
assertCircuitInsertProjectCommand(command);
return command;
}
export function createCircuitDeleteProjectCommand(
circuitId: string,
expectedCircuitListId: string
): CircuitDeleteProjectCommand {
const command: CircuitDeleteProjectCommand = {
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitDeleteCommandType,
payload: { circuitId, expectedCircuitListId },
};
assertCircuitDeleteProjectCommand(command);
return command;
}
export function assertCircuitInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitInsertProjectCommand {
if (
command.schemaVersion !== circuitStructureCommandSchemaVersion ||
command.type !== circuitInsertCommandType
) {
throw new Error("Unsupported circuit insert command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Circuit insert payload must be an object.");
}
const circuit = command.payload.circuit;
if (!isPlainObject(circuit)) {
throw new Error("Circuit insert command requires a circuit.");
}
assertNonEmptyString(circuit.id, "circuit.id");
assertNonEmptyString(circuit.circuitListId, "circuit.circuitListId");
assertNonEmptyString(circuit.sectionId, "circuit.sectionId");
assertNonEmptyString(
circuit.equipmentIdentifier,
"circuit.equipmentIdentifier"
);
assertNullableString(circuit.displayName, "circuit.displayName");
assertFiniteNumber(circuit.sortOrder, "circuit.sortOrder");
for (const field of [
"protectionType",
"protectionCharacteristic",
"cableType",
"cableCrossSection",
"rcdAssignment",
"terminalDesignation",
"controlRequirement",
"status",
"remark",
] as const) {
assertNullableString(circuit[field], `circuit.${field}`);
}
assertNullableNonNegativeNumber(
circuit.protectionRatedCurrent,
"circuit.protectionRatedCurrent"
);
assertNullableNonNegativeNumber(
circuit.cableLength,
"circuit.cableLength"
);
if (circuit.voltage !== null) {
assertFiniteNumber(circuit.voltage, "circuit.voltage");
if (circuit.voltage <= 0) {
throw new Error("circuit.voltage must be positive or null.");
}
}
if (typeof circuit.isReserve !== "boolean") {
throw new Error("circuit.isReserve must be a boolean.");
}
if (!Array.isArray(circuit.deviceRows)) {
throw new Error("circuit.deviceRows must be an array.");
}
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
throw new Error(
"Circuit reserve state must match whether device rows exist."
);
}
const rowIds = new Set<string>();
for (const row of circuit.deviceRows) {
assertCircuitDeviceRowInsertProjectCommand({
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType,
payload: { row },
});
if (row.circuitId !== circuit.id) {
throw new Error("Circuit device row belongs to a different circuit.");
}
if (rowIds.has(row.id)) {
throw new Error("Circuit insert command contains duplicate row ids.");
}
rowIds.add(row.id);
}
}
export function assertCircuitDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitDeleteProjectCommand {
if (
command.schemaVersion !== circuitStructureCommandSchemaVersion ||
command.type !== circuitDeleteCommandType
) {
throw new Error("Unsupported circuit delete command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Circuit delete payload must be an object.");
}
assertNonEmptyString(command.payload.circuitId, "circuitId");
assertNonEmptyString(
command.payload.expectedCircuitListId,
"expectedCircuitListId"
);
}
function assertNonEmptyString(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
function assertNullableString(value: unknown, field: string) {
if (value !== null && typeof value !== "string") {
throw new Error(`${field} must be a string or null.`);
}
}
function assertFiniteNumber(
value: unknown,
field: string
): asserts value is number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${field} must be a finite number.`);
}
}
function assertNullableNonNegativeNumber(value: unknown, field: string) {
if (value === null) {
return;
}
assertFiniteNumber(value, field);
if (value < 0) {
throw new Error(`${field} must not be negative.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,26 @@
import type { CircuitStructureProjectCommand } from "../models/circuit-structure-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitStructureCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitStructureProjectCommand;
}
export interface ExecutedCircuitStructureCommand {
revision: AppendedProjectRevision;
inverse: CircuitStructureProjectCommand;
}
export interface CircuitStructureProjectCommandStore {
execute(
input: ExecuteCircuitStructureCommandInput
): ExecutedCircuitStructureCommand;
}
@@ -13,6 +13,12 @@ import {
assertCircuitUpdateProjectCommand, assertCircuitUpdateProjectCommand,
circuitUpdateCommandType, circuitUpdateCommandType,
} from "../models/circuit-project-command.model.js"; } from "../models/circuit-project-command.model.js";
import {
assertCircuitDeleteProjectCommand,
assertCircuitInsertProjectCommand,
circuitDeleteCommandType,
circuitInsertCommandType,
} from "../models/circuit-structure-project-command.model.js";
import { import {
assertSerializedProjectCommand, assertSerializedProjectCommand,
type SerializedProjectCommand, type SerializedProjectCommand,
@@ -20,6 +26,7 @@ import {
import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js"; import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js";
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js"; import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js"; import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
import type { import type {
ExecuteProjectHistoryCommandInput, ExecuteProjectHistoryCommandInput,
ExecutedProjectCommand, ExecutedProjectCommand,
@@ -47,6 +54,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitStore: CircuitProjectCommandStore, private readonly circuitStore: CircuitProjectCommandStore,
private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore, private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore,
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore, private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly historyStore: ProjectHistoryStore private readonly historyStore: ProjectHistoryStore
) {} ) {}
@@ -140,6 +148,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case circuitInsertCommandType: {
assertCircuitInsertProjectCommand(input.command);
return this.circuitStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitDeleteCommandType: {
assertCircuitDeleteProjectCommand(input.command);
return this.circuitStructureStore.execute({
...input,
command: input.command,
}).revision;
}
default: default:
throw new Error( throw new Error(
`Unsupported project command type: ${input.command.type}.` `Unsupported project command type: ${input.command.type}.`
@@ -2,6 +2,7 @@ import { db } from "../../db/client.js";
import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js"; import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-project-command.repository.js"; import { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-project-command.repository.js";
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js";
@@ -10,10 +11,13 @@ export const circuitDeviceRowProjectCommandStore =
new CircuitDeviceRowProjectCommandRepository(db); new CircuitDeviceRowProjectCommandRepository(db);
export const circuitDeviceRowStructureProjectCommandStore = export const circuitDeviceRowStructureProjectCommandStore =
new CircuitDeviceRowStructureProjectCommandRepository(db); new CircuitDeviceRowStructureProjectCommandRepository(db);
export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db);
export const projectCommandService = new ProjectCommandService( export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore, circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore, circuitDeviceRowProjectCommandStore,
circuitDeviceRowStructureProjectCommandStore, circuitDeviceRowStructureProjectCommandStore,
circuitStructureProjectCommandStore,
projectHistoryStore projectHistoryStore
); );
@@ -0,0 +1,565 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { asc, eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import type { CircuitDeviceRowSnapshot } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import {
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
type CircuitSnapshot,
} from "../src/domain/models/circuit-structure-project-command.model.js";
interface TestFixture {
context: DatabaseContext;
circuitListId: string;
sectionId: string;
foreignCircuitListId: string;
foreignSectionId: string;
}
function createTestDatabase(): TestFixture {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Other project" },
])
.run();
const boards = new DistributionBoardRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections(
"project-1",
"UV-01"
);
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
"project-2",
"UV-02"
);
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
const foreignSection = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, foreignBoard.id))
.get();
assert.ok(section);
assert.ok(foreignSection);
context.db
.insert(projectDevices)
.values([
{
id: "device-1",
projectId: "project-1",
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
id: "device-foreign",
projectId: "project-2",
name: "Fremdgerät",
displayName: "Fremdgerät",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
])
.run();
context.db
.insert(rooms)
.values([
{
id: "room-1",
projectId: "project-1",
roomNumber: "001",
roomName: "Büro",
},
{
id: "room-foreign",
projectId: "project-2",
roomNumber: "999",
roomName: "Fremdraum",
},
])
.run();
context.db
.insert(circuits)
.values({
id: "circuit-existing",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Bestand",
sortOrder: 10,
protectionType: "LS",
protectionRatedCurrent: 16,
protectionCharacteristic: "B",
cableType: "NYM-J",
cableCrossSection: "3x1,5",
cableLength: 18.5,
rcdAssignment: "FI-1",
terminalDesignation: "X1",
voltage: 230,
controlRequirement: "DALI",
status: "planned",
isReserve: 0,
remark: "Vollständig",
})
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-existing-1",
circuitId: "circuit-existing",
linkedProjectDeviceId: "device-1",
legacyConsumerId: "legacy-1",
sortOrder: 10,
name: "Leuchte A",
displayName: "Leuchte A lokal",
phaseType: "single_phase",
category: "lighting",
roomId: "room-1",
roomNumberSnapshot: "001",
roomNameSnapshot: "Büro",
quantity: 2,
powerPerUnit: 0.1,
simultaneityFactor: 0.8,
cosPhi: 0.9,
overriddenFields: "[\"displayName\"]",
},
{
id: "row-existing-2",
circuitId: "circuit-existing",
sortOrder: 20,
name: "Leuchte B",
displayName: "Leuchte B",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
])
.run();
return {
context,
circuitListId: board.id,
sectionId: section.id,
foreignCircuitListId: foreignBoard.id,
foreignSectionId: foreignSection.id,
};
}
function createRow(
circuitId: string,
id: string,
sortOrder: number,
overrides: Partial<CircuitDeviceRowSnapshot> = {}
): CircuitDeviceRowSnapshot {
return {
id,
circuitId,
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder,
name: `Gerät ${id}`,
displayName: `Gerät ${id}`,
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: null,
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
overriddenFields: null,
...overrides,
};
}
function createCircuitSnapshot(
fixture: TestFixture,
overrides: Partial<CircuitSnapshot> = {}
): CircuitSnapshot {
const id = overrides.id ?? "circuit-new";
const deviceRows =
overrides.deviceRows ??
[
createRow(id, "row-new-1", 10, {
linkedProjectDeviceId: "device-1",
roomId: "room-1",
}),
createRow(id, "row-new-2", 20),
];
return {
id,
circuitListId: fixture.circuitListId,
sectionId: fixture.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Neuer Stromkreis",
sortOrder: 20,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: null,
status: null,
isReserve: deviceRows.length === 0,
remark: null,
deviceRows,
...overrides,
};
}
function getCircuit(
context: DatabaseContext,
circuitId: string
) {
return context.db
.select()
.from(circuits)
.where(eq(circuits.id, circuitId))
.get();
}
function getRows(context: DatabaseContext, circuitId: string) {
return context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuitId))
.orderBy(
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
}
describe("circuit structure project-command repository", () => {
it("inserts a multi-device circuit and supports persisted undo and redo", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const command = createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture)
);
const inserted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(getCircuit(fixture.context, "circuit-new")?.isReserve, 0);
assert.deepEqual(
getRows(fixture.context, "circuit-new").map((row) => row.id),
["row-new-1", "row-new-2"]
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(getCircuit(fixture.context, "circuit-new"), undefined);
assert.equal(getRows(fixture.context, "circuit-new").length, 0);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command,
});
assert.deepEqual(
getRows(fixture.context, "circuit-new").map((row) => row.id),
["row-new-1", "row-new-2"]
);
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: inserted.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
fixture.context.close();
}
});
it("inserts and removes an empty reserve circuit", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const inserted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture, {
id: "circuit-reserve",
equipmentIdentifier: "-1F3",
deviceRows: [],
isReserve: true,
})
),
});
assert.equal(
getCircuit(fixture.context, "circuit-reserve")?.isReserve,
1
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(
getCircuit(fixture.context, "circuit-reserve"),
undefined
);
} finally {
fixture.context.close();
}
});
it("deletes and restores a complete multi-device circuit block", () => {
const fixture = createTestDatabase();
try {
const beforeCircuit = getCircuit(
fixture.context,
"circuit-existing"
);
const beforeRows = getRows(fixture.context, "circuit-existing");
assert.ok(beforeCircuit);
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const deleted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeleteProjectCommand(
"circuit-existing",
fixture.circuitListId
),
});
assert.equal(
getCircuit(fixture.context, "circuit-existing"),
undefined
);
assert.equal(getRows(fixture.context, "circuit-existing").length, 0);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: deleted.revision.changeSetId,
command: deleted.inverse,
});
assert.deepEqual(
getCircuit(fixture.context, "circuit-existing"),
beforeCircuit
);
assert.deepEqual(
getRows(fixture.context, "circuit-existing"),
beforeRows
);
} finally {
fixture.context.close();
}
});
it("rolls back insert and delete for late history failures", () => {
const fixture = createTestDatabase();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_circuit_structure_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced circuit structure history failure');
END;
`);
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture)
),
}),
/forced circuit structure history failure/
);
assert.equal(getCircuit(fixture.context, "circuit-new"), undefined);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeleteProjectCommand(
"circuit-existing",
fixture.circuitListId
),
}),
/forced circuit structure history failure/
);
assert.ok(getCircuit(fixture.context, "circuit-existing"));
assert.equal(getRows(fixture.context, "circuit-existing").length, 2);
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back deletion for a stale expected revision", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitDeleteProjectCommand(
"circuit-existing",
fixture.circuitListId
),
}),
/at revision 0, expected 1/
);
assert.ok(getCircuit(fixture.context, "circuit-existing"));
assert.equal(getRows(fixture.context, "circuit-existing").length, 2);
} finally {
fixture.context.close();
}
});
it("rejects invalid ownership, identifiers, references and row ids", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const invalidSnapshots = [
createCircuitSnapshot(fixture, {
id: "foreign-list",
circuitListId: fixture.foreignCircuitListId,
sectionId: fixture.foreignSectionId,
}),
createCircuitSnapshot(fixture, {
id: "foreign-section",
sectionId: fixture.foreignSectionId,
}),
createCircuitSnapshot(fixture, {
id: "duplicate-bmk",
equipmentIdentifier: "-1F1",
}),
createCircuitSnapshot(fixture, {
id: "duplicate-row",
deviceRows: [
createRow("duplicate-row", "row-existing-1", 10),
],
}),
createCircuitSnapshot(fixture, {
id: "foreign-device",
deviceRows: [
createRow("foreign-device", "row-foreign-device", 10, {
linkedProjectDeviceId: "device-foreign",
}),
],
}),
createCircuitSnapshot(fixture, {
id: "foreign-room",
deviceRows: [
createRow("foreign-room", "row-foreign-room", 10, {
roomId: "room-foreign",
}),
],
}),
createCircuitSnapshot(fixture, {
id: "legacy-row",
deviceRows: [
createRow("legacy-row", "row-legacy", 10, {
legacyConsumerId: "legacy-user-value",
}),
],
}),
];
for (const snapshot of invalidSnapshots) {
assert.throws(() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(snapshot),
})
);
}
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
});
+104
View File
@@ -18,6 +18,11 @@ import {
createCircuitDeviceRowDeleteProjectCommand, createCircuitDeviceRowDeleteProjectCommand,
createCircuitDeviceRowInsertProjectCommand, createCircuitDeviceRowInsertProjectCommand,
} from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import {
assertCircuitInsertProjectCommand,
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
} from "../src/domain/models/circuit-structure-project-command.model.js";
describe("serialized project commands", () => { describe("serialized project commands", () => {
it("round-trips a versioned command envelope", () => { it("round-trips a versioned command envelope", () => {
@@ -240,3 +245,102 @@ describe("circuit device-row structure project commands", () => {
); );
}); });
}); });
describe("circuit structure project commands", () => {
const row = {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "lighting",
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
overriddenFields: null,
};
const circuit = {
id: "circuit-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F1",
displayName: "Beleuchtung",
sortOrder: 10,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: "DALI",
status: null,
isReserve: false,
remark: null,
deviceRows: [row],
};
it("captures complete circuit blocks and delete identities", () => {
const insert = createCircuitInsertProjectCommand(circuit);
const remove = createCircuitDeleteProjectCommand(
circuit.id,
circuit.circuitListId
);
assert.deepEqual(insert.payload.circuit, circuit);
assert.deepEqual(remove.payload, {
circuitId: "circuit-1",
expectedCircuitListId: "list-1",
});
});
it("rejects inconsistent reserve state, row ownership and duplicate ids", () => {
assert.throws(
() =>
createCircuitInsertProjectCommand({
...circuit,
isReserve: true,
}),
/reserve state/
);
assert.throws(
() =>
createCircuitInsertProjectCommand({
...circuit,
deviceRows: [{ ...row, circuitId: "other-circuit" }],
}),
/different circuit/
);
assert.throws(
() =>
createCircuitInsertProjectCommand({
...circuit,
deviceRows: [row, { ...row }],
}),
/duplicate row ids/
);
assert.throws(
() =>
assertCircuitInsertProjectCommand({
schemaVersion: 1,
type: "circuit.insert",
payload: {
circuit: { ...circuit, equipmentIdentifier: "" },
},
}),
/equipmentIdentifier/
);
});
});
+67
View File
@@ -10,6 +10,7 @@ import {
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js"; import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js"; import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
@@ -23,6 +24,7 @@ import { ProjectRevisionConflictError } from "../src/domain/errors/project-revis
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext { function createTestDatabase(): DatabaseContext {
@@ -75,6 +77,7 @@ function createService(context: DatabaseContext) {
new CircuitProjectCommandRepository(context.db), new CircuitProjectCommandRepository(context.db),
new CircuitDeviceRowProjectCommandRepository(context.db), new CircuitDeviceRowProjectCommandRepository(context.db),
new CircuitDeviceRowStructureProjectCommandRepository(context.db), new CircuitDeviceRowStructureProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db) new ProjectHistoryRepository(context.db)
); );
} }
@@ -255,6 +258,70 @@ describe("project command service", () => {
} }
}); });
it("dispatches complete circuit insertion and its persisted inverse", () => {
const context = createTestDatabase();
try {
const existingCircuit = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(existingCircuit);
const service = createService(context);
const inserted = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitInsertProjectCommand({
id: "circuit-new",
circuitListId: existingCircuit.circuitListId,
sectionId: existingCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Reserve",
sortOrder: 20,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: null,
status: null,
isReserve: true,
remark: null,
deviceRows: [],
}),
});
assert.equal(inserted.history.undoDepth, 1);
assert.equal(
context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-new"))
.get()?.equipmentIdentifier,
"-1F2"
);
const undone = createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(undone.history.redoDepth, 1);
assert.equal(
context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-new"))
.get(),
undefined
);
} finally {
context.close();
}
});
it("rejects unavailable history directions without writing a revision", () => { it("rejects unavailable history directions without writing a revision", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {