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
@@ -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
);