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
eligibility for the first commands. Public command, undo and redo endpoints
exist for Circuit and CircuitDeviceRow field updates as well as
CircuitDeviceRow insertion/deletion. Complete command coverage and the UI
cutover remain future work.
Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and
the UI cutover remain future work.
Required operations:
+9 -7
View File
@@ -23,13 +23,15 @@ concurrency checks.
- executes the eligible inverse or forward command as a new auditable
revision
The public dispatcher currently supports `circuit.update`,
`circuit-device-row.update`, `circuit-device-row.insert` and
`circuit-device-row.delete`, all with schema version `1`. Other command types
are rejected. Insert commands contain the complete row with its stable id;
delete commands identify the row and its expected circuit. This makes deletion
undoable without generating a replacement identity. The editor does not
consume these endpoints yet.
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit.delete`, `circuit-device-row.update`,
`circuit-device-row.insert` and `circuit-device-row.delete`, all with schema
version `1`. Other command types are rejected. Insert commands contain the
complete entity or circuit block with stable ids; delete commands include the
expected parent identity. Circuit snapshots contain zero, one or multiple
complete device rows. This makes deletion undoable without generating
replacement identities or renumbering circuits. The editor does not consume
these endpoints yet.
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
Strukturkommandos vorhanden. Beim Löschen wird die vollständige Zeile im
inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte
wiederherstellt. Das Grid verwendet diese Endpunkte noch nicht; seine sichtbare
Historie bleibt daher sitzungslokal. Die übrigen Struktur-, Synchronisierungs-
und Löschoperationen müssen vor der Frontend-Umstellung ebenfalls als
persistente Commands modelliert werden.
wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
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
@@ -176,12 +176,15 @@ Completed foundation:
- `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable
row ids, complete deleted-row snapshots and circuit reserve state through
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:
- extend the concrete project-scoped command union and executors beyond the
Circuit field and CircuitDeviceRow field/insert/delete slices; the generic
versioned command envelope is complete
Circuit field/insert/delete and CircuitDeviceRow field/insert/delete slices;
the generic versioned command envelope is complete
- route the remaining structural, synchronization and destructive domain
writes through the shared command, revision and stack transaction boundary
- 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
and stable ids; row mutation, circuit reserve state, revision and history
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
in one SQLite transaction
- a stale expected revision produces no history writes
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"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: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": "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-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:migrate": "drizzle-kit migrate",
"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 { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js";
import { rooms } from "../schema/rooms.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";
@@ -93,7 +95,7 @@ export class CircuitDeviceRowStructureProjectCommandRepository
);
}
this.assertCircuitInProject(database, projectId, row.circuitId);
this.assertReferencesInProject(database, projectId, row);
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
const existing = database
.select({ id: circuitDeviceRows.id })
@@ -194,69 +196,4 @@ export class CircuitDeviceRowStructureProjectCommandRepository
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,
circuitUpdateCommandType,
} from "../models/circuit-project-command.model.js";
import {
assertCircuitDeleteProjectCommand,
assertCircuitInsertProjectCommand,
circuitDeleteCommandType,
circuitInsertCommandType,
} from "../models/circuit-structure-project-command.model.js";
import {
assertSerializedProjectCommand,
type SerializedProjectCommand,
@@ -20,6 +26,7 @@ import {
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 { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
import type {
ExecuteProjectHistoryCommandInput,
ExecutedProjectCommand,
@@ -47,6 +54,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitStore: CircuitProjectCommandStore,
private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore,
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -140,6 +148,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).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:
throw new Error(
`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 { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-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 { ProjectCommandService } from "../../domain/services/project-command.service.js";
@@ -10,10 +11,13 @@ export const circuitDeviceRowProjectCommandStore =
new CircuitDeviceRowProjectCommandRepository(db);
export const circuitDeviceRowStructureProjectCommandStore =
new CircuitDeviceRowStructureProjectCommandRepository(db);
export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore,
circuitDeviceRowStructureProjectCommandStore,
circuitStructureProjectCommandStore,
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,
createCircuitDeviceRowInsertProjectCommand,
} 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", () => {
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 { 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 { 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";
@@ -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 { 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 { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext {
@@ -75,6 +77,7 @@ function createService(context: DatabaseContext) {
new CircuitProjectCommandRepository(context.db),
new CircuitDeviceRowProjectCommandRepository(context.db),
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(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", () => {
const context = createTestDatabase();
try {