Add project device update history

This commit is contained in:
2026-07-24 08:57:57 +02:00
parent 2668fc2f16
commit 0bc6c7372f
14 changed files with 785 additions and 13 deletions
@@ -0,0 +1,108 @@
import { and, eq } from "drizzle-orm";
import {
assertProjectDeviceUpdateProjectCommand,
createProjectDeviceUpdateProjectCommand,
type ProjectDeviceUpdateField,
type ProjectDeviceUpdatePatch,
type ProjectDeviceUpdateValues,
} from "../../domain/models/project-device-project-command.model.js";
import type {
ExecuteProjectDeviceUpdateCommandInput,
ProjectDeviceProjectCommandStore,
} from "../../domain/ports/project-device-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectDevices } from "../schema/project-devices.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type ProjectDeviceRow = typeof projectDevices.$inferSelect;
export class ProjectDeviceProjectCommandRepository
implements ProjectDeviceProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) {
assertProjectDeviceUpdateProjectCommand(input.command);
return this.database.transaction((tx) => {
const current = tx
.select()
.from(projectDevices)
.where(
and(
eq(
projectDevices.id,
input.command.payload.projectDeviceId
),
eq(projectDevices.projectId, input.projectId)
)
)
.get();
if (!current) {
throw new Error(
"Project device does not belong to project."
);
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as ProjectDeviceUpdatePatch;
const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
getProjectDeviceFieldValue(current, change.field),
])
) as ProjectDeviceUpdatePatch;
const inverse = createProjectDeviceUpdateProjectCommand(
current.id,
inversePatch
);
const updated = tx
.update(projectDevices)
.set(patch)
.where(
and(
eq(projectDevices.id, current.id),
eq(projectDevices.projectId, input.projectId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Project device changed before command execution."
);
}
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 };
});
}
}
function getProjectDeviceFieldValue<
TField extends ProjectDeviceUpdateField,
>(
projectDevice: ProjectDeviceRow,
field: TField
): ProjectDeviceUpdateValues[TField] {
return projectDevice[field] as ProjectDeviceUpdateValues[TField];
}
@@ -0,0 +1,194 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectDeviceUpdateCommandType =
"project-device.update" as const;
export const projectDeviceUpdateCommandSchemaVersion = 1 as const;
export interface ProjectDeviceUpdateValues {
name: string;
displayName: string;
phaseType: "single_phase" | "three_phase";
connectionKind: string | null;
costGroup: string | null;
category: string | null;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi: number | null;
remark: string | null;
voltageV: number | null;
}
export type ProjectDeviceUpdateField =
keyof ProjectDeviceUpdateValues;
export type ProjectDeviceUpdatePatch =
Partial<ProjectDeviceUpdateValues>;
export interface ProjectDeviceUpdateFieldChange<
TField extends ProjectDeviceUpdateField = ProjectDeviceUpdateField,
> {
field: TField;
value: ProjectDeviceUpdateValues[TField];
}
export interface ProjectDeviceUpdateCommandPayload {
projectDeviceId: string;
changes: ProjectDeviceUpdateFieldChange[];
}
export interface ProjectDeviceUpdateProjectCommand
extends SerializedProjectCommand<ProjectDeviceUpdateCommandPayload> {
schemaVersion: typeof projectDeviceUpdateCommandSchemaVersion;
type: typeof projectDeviceUpdateCommandType;
}
export function createProjectDeviceUpdateProjectCommand(
projectDeviceId: string,
patch: ProjectDeviceUpdatePatch
): ProjectDeviceUpdateProjectCommand {
const command: ProjectDeviceUpdateProjectCommand = {
schemaVersion: projectDeviceUpdateCommandSchemaVersion,
type: projectDeviceUpdateCommandType,
payload: {
projectDeviceId,
changes: Object.entries(patch).map(([field, value]) => ({
field: field as ProjectDeviceUpdateField,
value,
})) as ProjectDeviceUpdateFieldChange[],
},
};
assertProjectDeviceUpdateProjectCommand(command);
return command;
}
export function assertProjectDeviceUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectDeviceUpdateProjectCommand {
if (
command.schemaVersion !== projectDeviceUpdateCommandSchemaVersion ||
command.type !== projectDeviceUpdateCommandType
) {
throw new Error("Unsupported project-device update command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Project-device update payload must be an object.");
}
const { projectDeviceId, changes } = command.payload;
if (
typeof projectDeviceId !== "string" ||
!projectDeviceId.trim()
) {
throw new Error(
"Project-device update command requires a project device id."
);
}
if (!Array.isArray(changes) || changes.length === 0) {
throw new Error(
"Project-device update command requires at least one change."
);
}
const seenFields = new Set<string>();
for (const change of changes) {
if (!isPlainObject(change) || typeof change.field !== "string") {
throw new Error(
"Project-device update command contains an invalid change."
);
}
if (
!isProjectDeviceUpdateField(change.field) ||
seenFields.has(change.field)
) {
throw new Error(
"Project-device update command contains an invalid or duplicate field."
);
}
assertProjectDeviceUpdateFieldValue(
change.field,
change.value
);
seenFields.add(change.field);
}
}
function assertProjectDeviceUpdateFieldValue(
field: ProjectDeviceUpdateField,
value: unknown
) {
if (field === "name" || field === "displayName") {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
return;
}
if (field === "phaseType") {
if (value !== "single_phase" && value !== "three_phase") {
throw new Error("phaseType is invalid.");
}
return;
}
if (
field === "quantity" ||
field === "powerPerUnit" ||
field === "simultaneityFactor"
) {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < 0 ||
(field === "simultaneityFactor" && value > 1)
) {
throw new Error(`${field} is outside its allowed range.`);
}
return;
}
if (field === "cosPhi") {
if (
value !== null &&
(typeof value !== "number" ||
!Number.isFinite(value) ||
value < 0 ||
value > 1)
) {
throw new Error("cosPhi must be between zero and one or null.");
}
return;
}
if (field === "voltageV") {
if (
value !== null &&
(typeof value !== "number" ||
!Number.isFinite(value) ||
value <= 0)
) {
throw new Error("voltageV must be positive or null.");
}
return;
}
if (value !== null && typeof value !== "string") {
throw new Error(`${field} must be a string or null.`);
}
}
function isProjectDeviceUpdateField(
value: string
): value is ProjectDeviceUpdateField {
return [
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"voltageV",
].includes(value);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,26 @@
import type { ProjectDeviceUpdateProjectCommand } from "../models/project-device-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteProjectDeviceUpdateCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ProjectDeviceUpdateProjectCommand;
}
export interface ExecutedProjectDeviceUpdateCommand {
revision: AppendedProjectRevision;
inverse: ProjectDeviceUpdateProjectCommand;
}
export interface ProjectDeviceProjectCommandStore {
executeUpdate(
input: ExecuteProjectDeviceUpdateCommandInput
): ExecutedProjectDeviceUpdateCommand;
}
@@ -41,6 +41,10 @@ import {
assertProjectDeviceRowSyncProjectCommand,
projectDeviceRowSyncCommandType,
} from "../models/project-device-row-sync-project-command.model.js";
import {
assertProjectDeviceUpdateProjectCommand,
projectDeviceUpdateCommandType,
} from "../models/project-device-project-command.model.js";
import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js";
import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js";
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
@@ -58,6 +62,7 @@ import type {
ProjectHistoryDirection,
ProjectHistoryStore,
} from "../ports/project-history.store.js";
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
@@ -80,6 +85,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -225,6 +231,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case projectDeviceUpdateCommandType: {
assertProjectDeviceUpdateProjectCommand(input.command);
return this.projectDeviceStore.executeUpdate({
...input,
command: input.command,
}).revision;
}
default:
throw new Error(
`Unsupported project command type: ${input.command.type}.`
@@ -7,6 +7,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../../db/reposito
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-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 { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
@@ -23,6 +24,8 @@ export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(db);
export const circuitSectionRenumberProjectCommandStore =
new CircuitSectionRenumberProjectCommandRepository(db);
export const projectDeviceProjectCommandStore =
new ProjectDeviceProjectCommandRepository(db);
export const projectDeviceRowSyncProjectCommandStore =
new ProjectDeviceRowSyncProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
@@ -34,6 +37,7 @@ export const projectCommandService = new ProjectCommandService(
circuitStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore,
circuitSectionRenumberProjectCommandStore,
projectDeviceProjectCommandStore,
projectDeviceRowSyncProjectCommandStore,
projectHistoryStore
);