From 0bc6c7372fddc3203d68fbec465958d89bf3fd07 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 24 Jul 2026 08:57:57 +0200 Subject: [PATCH] Add project device update history --- AGENTS.md | 3 +- docs/circuit-list-editor-api.md | 21 +- docs/current-architecture.md | 6 + ...history-and-external-model-architecture.md | 6 +- docs/spec/07-implementation-phases-todo.md | 2 + package.json | 4 +- ...oject-device-project-command.repository.ts | 108 +++++++ .../project-device-project-command.model.ts | 194 ++++++++++++ .../project-device-project-command.store.ts | 26 ++ .../services/project-command.service.ts | 13 + .../composition/project-command-stores.ts | 4 + tests/project-command.model.test.ts | 60 ++++ tests/project-command.service.test.ts | 59 ++++ ...-device-project-command.repository.test.ts | 292 ++++++++++++++++++ 14 files changed, 785 insertions(+), 13 deletions(-) create mode 100644 src/db/repositories/project-device-project-command.repository.ts create mode 100644 src/domain/models/project-device-project-command.model.ts create mode 100644 src/domain/ports/project-device-project-command.store.ts create mode 100644 tests/project-device-project-command.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index 472fda4..98f509a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -234,7 +234,8 @@ without changing equipment identifiers. Explicit complete-section renumbering is persisted through a separate collision-safe command and is never triggered by sorting or moving. Project-device synchronization, disconnect and reconnect are persisted as one atomic multi-row command with complete expected/target row -snapshots, including link and override metadata. +snapshots, including link and override metadata. Canonical ProjectDevice field +updates are also persisted and never synchronize linked rows implicitly. Required operations: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index cef4241..410b6d3 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -28,14 +28,14 @@ The public dispatcher currently supports `circuit.update`, `circuit.insert`, `circuit-device-row.insert`, `circuit-device-row.delete`, `circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`, `circuit.reorder-section`, `circuit.renumber-section` and -`project-device.sync-rows`, 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. Move commands -contain each row's expected and target circuit plus its exact expected and -target sort order. This makes deletion and moves undoable without generating -replacement identities or renumbering circuits. The editor does not consume -these endpoints yet. +`project-device.update` and `project-device.sync-rows`, 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. Move commands contain each row's expected and target circuit plus +its exact expected and target sort order. This makes deletion and moves +undoable without generating replacement identities or renumbering circuits. +The editor does not consume these endpoints yet. `circuit-device-row.move` targets existing circuits in the same circuit list. `circuit-device-row.move-with-new-circuit` atomically creates exactly one @@ -63,6 +63,11 @@ All rows, the inverse command, revision and history-stack transition commit or roll back together. Disconnect/reconnect may only change the link; stale row snapshots and cross-project devices or rows are rejected. +`project-device.update` changes one or multiple canonical ProjectDevice fields +and derives its inverse from the persisted device. It validates project +ownership and never writes linked `CircuitDeviceRow` values; synchronization +remains a separate explicit command. + Example: ```json diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 04d8db2..2db23b4 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -111,6 +111,12 @@ werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stell exakt die vorherigen lokalen Werte wieder her. Die bestehenden Projektgeräte-Endpunkte verwenden diesen Command bis zur Frontend-Umstellung noch nicht. +`project-device.update` versioniert Änderungen an den kanonischen +Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem +gespeicherten Gerät und schreibt Geräteänderung, Revision und Historienstapel +atomar. Verknüpfte Stromkreiszeilen werden dabei bewusst nicht automatisch +synchronisiert. Der bestehende ProjectDevice-`PUT`-Endpunkt verwendet diesen +Command bis zum UI-Cutover noch nicht. ## Projektgeräte diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 0dd20f4..dcd92b5 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -195,13 +195,15 @@ Completed foundation: - `project-device.sync-rows` records synchronization, disconnect and reconnect as one atomic multi-row operation; complete expected/target sync snapshots protect local values, links and override metadata against silent stale writes +- `project-device.update` persists canonical source-device field changes + independently and never synchronizes linked rows implicitly Remaining constraints before completing persistent history: - extend the concrete project-scoped command union and executors beyond the Circuit field/insert/delete and CircuitDeviceRow - field/insert/delete/move, section-reorder/renumber and ProjectDevice row-sync - slices; the generic versioned command envelope is complete + field/insert/delete/move, section-reorder/renumber, ProjectDevice field-update + and row-sync slices; the generic versioned command envelope is complete - route the remaining structural and destructive domain writes through the shared command, revision and stack transaction boundary - replace the session-local frontend command stack only after server-side diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 5ec800a..b91b7ca 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -409,6 +409,8 @@ Implemented foundation: - ProjectDevice row synchronization, disconnect and reconnect persist complete expected/target sync snapshots in one atomic multi-row command; undo restores local values, links and override metadata without silent overwrites +- canonical ProjectDevice field updates persist independently with exact + inverses and never overwrite linked CircuitDeviceRow values implicitly - revision metadata, change-set payloads and the project counter are committed in one SQLite transaction - a stale expected revision produces no history writes diff --git a/package.json b/package.json index cf70e9b..f6fd7fa 100644 --- a/package.json +++ b/package.json @@ -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/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.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/circuit-device-row-move-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/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.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/circuit-device-row-move-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/project-device-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.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/circuit-device-row-move-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/project-device-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.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/circuit-device-row-move-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", diff --git a/src/db/repositories/project-device-project-command.repository.ts b/src/db/repositories/project-device-project-command.repository.ts new file mode 100644 index 0000000..e249ee3 --- /dev/null +++ b/src/db/repositories/project-device-project-command.repository.ts @@ -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]; +} diff --git a/src/domain/models/project-device-project-command.model.ts b/src/domain/models/project-device-project-command.model.ts new file mode 100644 index 0000000..117e2e5 --- /dev/null +++ b/src/domain/models/project-device-project-command.model.ts @@ -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; + +export interface ProjectDeviceUpdateFieldChange< + TField extends ProjectDeviceUpdateField = ProjectDeviceUpdateField, +> { + field: TField; + value: ProjectDeviceUpdateValues[TField]; +} + +export interface ProjectDeviceUpdateCommandPayload { + projectDeviceId: string; + changes: ProjectDeviceUpdateFieldChange[]; +} + +export interface ProjectDeviceUpdateProjectCommand + extends SerializedProjectCommand { + 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 +): 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(); + 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 { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/project-device-project-command.store.ts b/src/domain/ports/project-device-project-command.store.ts new file mode 100644 index 0000000..c3851d8 --- /dev/null +++ b/src/domain/ports/project-device-project-command.store.ts @@ -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; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index ca30ce3..f0f65fb 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -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}.` diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 0373bb3..32de37f 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -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 ); diff --git a/tests/project-command.model.test.ts b/tests/project-command.model.test.ts index 236b2ba..7741c9d 100644 --- a/tests/project-command.model.test.ts +++ b/tests/project-command.model.test.ts @@ -42,6 +42,10 @@ import { createProjectDeviceRowSyncProjectCommand, type ProjectDeviceSyncRowSnapshot, } from "../src/domain/models/project-device-row-sync-project-command.model.js"; +import { + assertProjectDeviceUpdateProjectCommand, + createProjectDeviceUpdateProjectCommand, +} from "../src/domain/models/project-device-project-command.model.js"; describe("serialized project commands", () => { it("round-trips a versioned command envelope", () => { @@ -293,6 +297,62 @@ describe("project-device row sync project commands", () => { }); }); +describe("project-device update project commands", () => { + it("creates typed canonical field changes with null clearing", () => { + const command = createProjectDeviceUpdateProjectCommand( + "project-device-1", + { + displayName: "Flurbeleuchtung", + cosPhi: null, + voltageV: 230, + } + ); + + assert.doesNotThrow(() => + assertProjectDeviceUpdateProjectCommand(command) + ); + assert.deepEqual(command.payload.changes, [ + { field: "displayName", value: "Flurbeleuchtung" }, + { field: "cosPhi", value: null }, + { field: "voltageV", value: 230 }, + ]); + }); + + it("rejects empty, duplicate and invalid canonical values", () => { + assert.throws( + () => + createProjectDeviceUpdateProjectCommand( + "project-device-1", + {} + ), + /at least one change/ + ); + assert.throws( + () => + assertProjectDeviceUpdateProjectCommand({ + schemaVersion: 1, + type: "project-device.update", + payload: { + projectDeviceId: "project-device-1", + changes: [ + { field: "quantity", value: 1 }, + { field: "quantity", value: 2 }, + ], + }, + }), + /duplicate field/ + ); + assert.throws( + () => + createProjectDeviceUpdateProjectCommand( + "project-device-1", + { simultaneityFactor: 1.1 } + ), + /outside its allowed range/ + ); + }); +}); + describe("circuit device-row structure project commands", () => { const row = { id: "row-1", diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index d4dfce6..947a0fe 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -16,6 +16,7 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/reposi 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 { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; @@ -37,6 +38,7 @@ import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/ import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js"; import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js"; import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js"; +import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; function createTestDatabase(): DatabaseContext { @@ -93,6 +95,7 @@ function createService(context: DatabaseContext) { new CircuitStructureProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db), + new ProjectDeviceProjectCommandRepository(context.db), new ProjectDeviceRowSyncProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); @@ -266,6 +269,62 @@ describe("project command service", () => { } }); + it("dispatches project-device updates and their persisted inverse", () => { + const context = createTestDatabase(); + try { + context.db + .insert(projectDevices) + .values({ + id: "project-device-1", + projectId: "project-1", + name: "Luminaire", + displayName: "Office lighting", + phaseType: "single_phase", + quantity: 4, + powerPerUnit: 0.04, + simultaneityFactor: 0.8, + }) + .run(); + const service = createService(context); + const updated = service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createProjectDeviceUpdateProjectCommand( + "project-device-1", + { + displayName: "Flurbeleuchtung", + powerPerUnit: 0.05, + } + ), + }); + assert.equal(updated.history.undoDepth, 1); + assert.equal( + context.db + .select() + .from(projectDevices) + .where(eq(projectDevices.id, "project-device-1")) + .get()?.displayName, + "Flurbeleuchtung" + ); + + const undone = createService(context).undo({ + projectId: "project-1", + expectedRevision: 1, + }); + assert.equal(undone.history.redoDepth, 1); + assert.equal( + context.db + .select() + .from(projectDevices) + .where(eq(projectDevices.id, "project-device-1")) + .get()?.displayName, + "Office lighting" + ); + } finally { + context.close(); + } + }); + it("rejects stale undo and preserves the domain value and stack", () => { const context = createTestDatabase(); try { diff --git a/tests/project-device-project-command.repository.test.ts b/tests/project-device-project-command.repository.test.ts new file mode 100644 index 0000000..abad57d --- /dev/null +++ b/tests/project-device-project-command.repository.test.ts @@ -0,0 +1,292 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.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 { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; +import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js"; + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db + .insert(projects) + .values([ + { id: "project-1", name: "Test project" }, + { id: "project-2", name: "Other project" }, + ]) + .run(); + context.db + .insert(projectDevices) + .values([ + { + id: "project-device-1", + projectId: "project-1", + name: "Luminaire", + displayName: "Office lighting", + phaseType: "single_phase", + connectionKind: "fixed", + costGroup: "440", + category: "lighting", + quantity: 4, + powerPerUnit: 0.04, + simultaneityFactor: 0.8, + cosPhi: 0.95, + remark: "DALI", + voltageV: 230, + }, + { + id: "project-device-foreign", + projectId: "project-2", + name: "Foreign device", + displayName: "Foreign device", + phaseType: "three_phase", + quantity: 1, + powerPerUnit: 1, + simultaneityFactor: 1, + }, + ]) + .run(); + return context; +} + +function getProjectDevice( + context: DatabaseContext, + projectDeviceId = "project-device-1" +) { + const device = context.db + .select() + .from(projectDevices) + .where(eq(projectDevices.id, projectDeviceId)) + .get(); + assert.ok(device); + return device; +} + +describe("project-device project-command repository", () => { + it("updates canonical fields and supports persisted undo and redo", () => { + const context = createTestDatabase(); + try { + const store = new ProjectDeviceProjectCommandRepository( + context.db + ); + const command = createProjectDeviceUpdateProjectCommand( + "project-device-1", + { + displayName: "Flurbeleuchtung", + phaseType: "three_phase", + connectionKind: null, + powerPerUnit: 0.05, + cosPhi: null, + voltageV: 400, + } + ); + const updated = store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.deepEqual( + { + displayName: getProjectDevice(context).displayName, + phaseType: getProjectDevice(context).phaseType, + connectionKind: getProjectDevice(context).connectionKind, + powerPerUnit: getProjectDevice(context).powerPerUnit, + cosPhi: getProjectDevice(context).cosPhi, + voltageV: getProjectDevice(context).voltageV, + }, + { + displayName: "Flurbeleuchtung", + phaseType: "three_phase", + connectionKind: null, + powerPerUnit: 0.05, + cosPhi: null, + voltageV: 400, + } + ); + + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: updated.revision.changeSetId, + command: updated.inverse, + }); + assert.deepEqual( + { + displayName: getProjectDevice(context).displayName, + phaseType: getProjectDevice(context).phaseType, + connectionKind: getProjectDevice(context).connectionKind, + powerPerUnit: getProjectDevice(context).powerPerUnit, + cosPhi: getProjectDevice(context).cosPhi, + voltageV: getProjectDevice(context).voltageV, + }, + { + displayName: "Office lighting", + phaseType: "single_phase", + connectionKind: "fixed", + powerPerUnit: 0.04, + cosPhi: 0.95, + voltageV: 230, + } + ); + + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: updated.revision.changeSetId, + command, + }); + assert.deepEqual( + new ProjectHistoryRepository(context.db).getState( + "project-1" + ), + { + projectId: "project-1", + currentRevision: 3, + undoDepth: 1, + redoDepth: 0, + undoChangeSetId: updated.revision.changeSetId, + redoChangeSetId: null, + } + ); + } finally { + context.close(); + } + }); + + it("rejects project devices from another project", () => { + const context = createTestDatabase(); + try { + const store = new ProjectDeviceProjectCommandRepository( + context.db + ); + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceUpdateProjectCommand( + "project-device-foreign", + { displayName: "Not allowed" } + ), + }), + /does not belong to project/ + ); + assert.equal( + getProjectDevice( + context, + "project-device-foreign" + ).displayName, + "Foreign device" + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + context.close(); + } + }); + + it("rolls back the update for a stale project revision", () => { + const context = createTestDatabase(); + try { + const store = new ProjectDeviceProjectCommandRepository( + context.db + ); + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceUpdateProjectCommand( + "project-device-1", + { displayName: "First change" } + ), + }); + + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceUpdateProjectCommand( + "project-device-1", + { displayName: "Stale change" } + ), + }), + (error) => + error instanceof ProjectRevisionConflictError && + error.actualRevision === 1 + ); + assert.equal( + getProjectDevice(context).displayName, + "First change" + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 1 + ); + } finally { + context.close(); + } + }); + + it("rolls back the update after a late history failure", () => { + const context = createTestDatabase(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_project_device_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced project-device history failure'); + END; + `); + const store = new ProjectDeviceProjectCommandRepository( + context.db + ); + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceUpdateProjectCommand( + "project-device-1", + { + displayName: "Must roll back", + quantity: 9, + } + ), + }), + /forced project-device history failure/ + ); + assert.equal( + getProjectDevice(context).displayName, + "Office lighting" + ); + assert.equal(getProjectDevice(context).quantity, 4); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + context.close(); + } + }); +});