From bd5f4f925f9375becc9287802c304c10ef7dbfa4 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 23 Jul 2026 22:09:53 +0200 Subject: [PATCH] Add device row history commands --- AGENTS.md | 5 +- docs/circuit-list-editor-api.md | 10 +- docs/current-architecture.md | 12 +- ...history-and-external-model-architecture.md | 7 +- docs/spec/07-implementation-phases-todo.md | 3 + package.json | 4 +- ...ow-structure-project-command.repository.ts | 262 +++++++++++ ...ice-row-structure-project-command.model.ts | 186 ++++++++ ...ice-row-structure-project-command.store.ts | 26 + .../services/project-command.service.ts | 22 + .../composition/project-command-stores.ts | 4 + ...ructure-project-command.repository.test.ts | 445 ++++++++++++++++++ tests/project-command.model.test.ts | 74 +++ tests/project-command.service.test.ts | 62 +++ 14 files changed, 1109 insertions(+), 13 deletions(-) create mode 100644 src/db/repositories/circuit-device-row-structure-project-command.repository.ts create mode 100644 src/domain/models/circuit-device-row-structure-project-command.model.ts create mode 100644 src/domain/ports/circuit-device-row-structure-project-command.store.ts create mode 100644 tests/circuit-device-row-structure-project-command.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index 46fdb40..789d050 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,8 +223,9 @@ After saving, the row becomes linked to the new project device. 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. Complete command coverage -and the UI cutover remain future work. +exist for Circuit and CircuitDeviceRow field updates as well as +CircuitDeviceRow insertion/deletion. Complete command coverage and the UI +cutover remain future work. Required operations: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 1b0f0f5..fc20959 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -23,9 +23,13 @@ concurrency checks. - executes the eligible inverse or forward command as a new auditable revision -The public dispatcher currently supports `circuit.update` and -`circuit-device-row.update`, both with schema version `1`. Other command types -are rejected. The editor does not consume these endpoints yet. +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. Example: diff --git a/docs/current-architecture.md b/docs/current-architecture.md index b74eca1..9288298 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -78,10 +78,14 @@ persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über `GET /api/projects/:projectId/history` lesbar. Ein zentraler Dispatcher führt die unterstützten Typen `circuit.update` und `circuit-device-row.update` über -öffentliche Command-, Undo- und Redo-Endpunkte aus. Das Grid verwendet diese -Endpunkte noch nicht; seine sichtbare Historie bleibt daher sitzungslokal. -Struktur-, Synchronisierungs- und Löschoperationen müssen vor der -Frontend-Umstellung ebenfalls als persistente Commands modelliert werden. +öffentliche Command-, Undo- und Redo-Endpunkte aus. Zusätzlich sind +`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. ## Projektgeräte diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 8c442f0..c860f6d 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -173,12 +173,15 @@ Completed foundation: to SQLite - public command, undo and redo endpoints require an expected revision; undo and redo reconstruct the eligible persisted inverse or forward command +- `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 Remaining constraints before completing persistent history: - extend the concrete project-scoped command union and executors beyond the - internal Circuit and CircuitDeviceRow update slices; the generic versioned - command envelope is complete + Circuit field 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 diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index b0d4ed9..985981a 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -390,6 +390,9 @@ Implemented foundation: - a central application dispatcher reconstructs persisted commands and exposes optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow field updates +- 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 - 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 56648ee..35ca411 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/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/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/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-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", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/repositories/circuit-device-row-structure-project-command.repository.ts b/src/db/repositories/circuit-device-row-structure-project-command.repository.ts new file mode 100644 index 0000000..19f9159 --- /dev/null +++ b/src/db/repositories/circuit-device-row-structure-project-command.repository.ts @@ -0,0 +1,262 @@ +import { and, eq } from "drizzle-orm"; +import { + assertCircuitDeviceRowDeleteProjectCommand, + assertCircuitDeviceRowInsertProjectCommand, + circuitDeviceRowDeleteCommandType, + circuitDeviceRowInsertCommandType, + createCircuitDeviceRowDeleteProjectCommand, + createCircuitDeviceRowInsertProjectCommand, + type CircuitDeviceRowSnapshot, + type CircuitDeviceRowStructureProjectCommand, +} from "../../domain/models/circuit-device-row-structure-project-command.model.js"; +import type { + CircuitDeviceRowStructureProjectCommandStore, + ExecuteCircuitDeviceRowStructureCommandInput, +} from "../../domain/ports/circuit-device-row-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 { circuits } from "../schema/circuits.js"; +import { projectDevices } from "../schema/project-devices.js"; +import { rooms } from "../schema/rooms.js"; +import { applyProjectHistoryTransition } from "./project-history.persistence.js"; +import { appendProjectRevision } from "./project-revision.persistence.js"; + +export class CircuitDeviceRowStructureProjectCommandRepository + implements CircuitDeviceRowStructureProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteCircuitDeviceRowStructureCommandInput) { + 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: ExecuteCircuitDeviceRowStructureCommandInput["source"], + command: CircuitDeviceRowStructureProjectCommand + ): CircuitDeviceRowStructureProjectCommand { + if (command.type === circuitDeviceRowInsertCommandType) { + assertCircuitDeviceRowInsertProjectCommand(command); + return this.insert( + database, + projectId, + source, + command.payload.row + ); + } + if (command.type === circuitDeviceRowDeleteCommandType) { + assertCircuitDeviceRowDeleteProjectCommand(command); + return this.delete( + database, + projectId, + command.payload.rowId, + command.payload.expectedCircuitId + ); + } + throw new Error("Unsupported circuit device-row structure command."); + } + + private insert( + database: AppDatabase, + projectId: string, + source: ExecuteCircuitDeviceRowStructureCommandInput["source"], + row: CircuitDeviceRowSnapshot + ) { + if (source === "user" && row.legacyConsumerId !== null) { + throw new Error( + "User commands cannot assign legacy consumer ids." + ); + } + this.assertCircuitInProject(database, projectId, row.circuitId); + this.assertReferencesInProject(database, projectId, row); + + const existing = database + .select({ id: circuitDeviceRows.id }) + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, row.id)) + .get(); + if (existing) { + throw new Error("Circuit device-row id already exists."); + } + + database.insert(circuitDeviceRows).values(row).run(); + const circuitUpdate = database + .update(circuits) + .set({ isReserve: 0 }) + .where(eq(circuits.id, row.circuitId)) + .run(); + if (circuitUpdate.changes !== 1) { + throw new Error("Circuit changed before device-row insertion."); + } + + return createCircuitDeviceRowDeleteProjectCommand( + row.id, + row.circuitId + ); + } + + private delete( + database: AppDatabase, + projectId: string, + rowId: string, + expectedCircuitId: string + ) { + const row = database + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, rowId)) + .get(); + if (!row || row.circuitId !== expectedCircuitId) { + throw new Error( + "Circuit device row changed before command execution." + ); + } + this.assertCircuitInProject(database, projectId, row.circuitId); + + const result = database + .delete(circuitDeviceRows) + .where( + and( + eq(circuitDeviceRows.id, rowId), + eq(circuitDeviceRows.circuitId, expectedCircuitId) + ) + ) + .run(); + if (result.changes !== 1) { + throw new Error("Circuit device row could not be deleted."); + } + + const remainingRow = database + .select({ id: circuitDeviceRows.id }) + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, expectedCircuitId)) + .limit(1) + .get(); + const circuitUpdate = database + .update(circuits) + .set({ isReserve: remainingRow ? 0 : 1 }) + .where(eq(circuits.id, expectedCircuitId)) + .run(); + if (circuitUpdate.changes !== 1) { + throw new Error("Circuit changed before device-row deletion."); + } + + return createCircuitDeviceRowInsertProjectCommand( + toCircuitDeviceRowSnapshot(row) + ); + } + + private assertCircuitInProject( + database: AppDatabase, + projectId: string, + circuitId: string + ) { + const circuit = database + .select({ id: circuits.id }) + .from(circuits) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuits.circuitListId) + ) + .where( + and( + eq(circuits.id, circuitId), + eq(circuitLists.projectId, projectId) + ) + ) + .get(); + if (!circuit) { + 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, + }; +} diff --git a/src/domain/models/circuit-device-row-structure-project-command.model.ts b/src/domain/models/circuit-device-row-structure-project-command.model.ts new file mode 100644 index 0000000..57efbdc --- /dev/null +++ b/src/domain/models/circuit-device-row-structure-project-command.model.ts @@ -0,0 +1,186 @@ +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const circuitDeviceRowInsertCommandType = + "circuit-device-row.insert" as const; +export const circuitDeviceRowDeleteCommandType = + "circuit-device-row.delete" as const; +export const circuitDeviceRowStructureCommandSchemaVersion = 1 as const; + +export interface CircuitDeviceRowSnapshot { + id: string; + circuitId: string; + linkedProjectDeviceId: string | null; + legacyConsumerId: string | null; + sortOrder: number; + name: string; + displayName: string; + phaseType: string | null; + connectionKind: string | null; + costGroup: string | null; + category: string | null; + level: string | null; + roomId: string | null; + roomNumberSnapshot: string | null; + roomNameSnapshot: string | null; + quantity: number; + powerPerUnit: number; + simultaneityFactor: number; + cosPhi: number | null; + remark: string | null; + overriddenFields: string | null; +} + +export interface CircuitDeviceRowInsertCommandPayload { + row: CircuitDeviceRowSnapshot; +} + +export interface CircuitDeviceRowDeleteCommandPayload { + rowId: string; + expectedCircuitId: string; +} + +export interface CircuitDeviceRowInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitDeviceRowStructureCommandSchemaVersion; + type: typeof circuitDeviceRowInsertCommandType; +} + +export interface CircuitDeviceRowDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitDeviceRowStructureCommandSchemaVersion; + type: typeof circuitDeviceRowDeleteCommandType; +} + +export type CircuitDeviceRowStructureProjectCommand = + | CircuitDeviceRowInsertProjectCommand + | CircuitDeviceRowDeleteProjectCommand; + +export function createCircuitDeviceRowInsertProjectCommand( + row: CircuitDeviceRowSnapshot +): CircuitDeviceRowInsertProjectCommand { + const command: CircuitDeviceRowInsertProjectCommand = { + schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, + type: circuitDeviceRowInsertCommandType, + payload: { row }, + }; + assertCircuitDeviceRowInsertProjectCommand(command); + return command; +} + +export function createCircuitDeviceRowDeleteProjectCommand( + rowId: string, + expectedCircuitId: string +): CircuitDeviceRowDeleteProjectCommand { + const command: CircuitDeviceRowDeleteProjectCommand = { + schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, + type: circuitDeviceRowDeleteCommandType, + payload: { rowId, expectedCircuitId }, + }; + assertCircuitDeviceRowDeleteProjectCommand(command); + return command; +} + +export function assertCircuitDeviceRowInsertProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitDeviceRowInsertProjectCommand { + if ( + command.schemaVersion !== + circuitDeviceRowStructureCommandSchemaVersion || + command.type !== circuitDeviceRowInsertCommandType + ) { + throw new Error("Unsupported circuit device-row insert command."); + } + if (!isPlainObject(command.payload)) { + throw new Error("Circuit device-row insert payload must be an object."); + } + const row = command.payload.row; + if (!isPlainObject(row)) { + throw new Error("Circuit device-row insert command requires a row."); + } + + assertNonEmptyString(row.id, "row.id"); + assertNonEmptyString(row.circuitId, "row.circuitId"); + assertNullableString(row.linkedProjectDeviceId, "row.linkedProjectDeviceId"); + assertNullableString(row.legacyConsumerId, "row.legacyConsumerId"); + assertFiniteNumber(row.sortOrder, "row.sortOrder"); + assertNonEmptyString(row.name, "row.name"); + assertNonEmptyString(row.displayName, "row.displayName"); + for (const field of [ + "phaseType", + "connectionKind", + "costGroup", + "category", + "level", + "roomId", + "roomNumberSnapshot", + "roomNameSnapshot", + "remark", + "overriddenFields", + ] as const) { + assertNullableString(row[field], `row.${field}`); + } + assertNonNegativeNumber(row.quantity, "row.quantity"); + assertNonNegativeNumber(row.powerPerUnit, "row.powerPerUnit"); + assertNonNegativeNumber( + row.simultaneityFactor, + "row.simultaneityFactor" + ); + if (row.cosPhi !== null) { + assertFiniteNumber(row.cosPhi, "row.cosPhi"); + if (row.cosPhi <= 0) { + throw new Error("row.cosPhi must be positive or null."); + } + } +} + +export function assertCircuitDeviceRowDeleteProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitDeviceRowDeleteProjectCommand { + if ( + command.schemaVersion !== + circuitDeviceRowStructureCommandSchemaVersion || + command.type !== circuitDeviceRowDeleteCommandType + ) { + throw new Error("Unsupported circuit device-row delete command."); + } + if (!isPlainObject(command.payload)) { + throw new Error("Circuit device-row delete payload must be an object."); + } + assertNonEmptyString(command.payload.rowId, "rowId"); + assertNonEmptyString( + command.payload.expectedCircuitId, + "expectedCircuitId" + ); +} + +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 assertNonNegativeNumber(value: unknown, field: string) { + assertFiniteNumber(value, field); + if (value < 0) { + throw new Error(`${field} must not be negative.`); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/circuit-device-row-structure-project-command.store.ts b/src/domain/ports/circuit-device-row-structure-project-command.store.ts new file mode 100644 index 0000000..3acf81a --- /dev/null +++ b/src/domain/ports/circuit-device-row-structure-project-command.store.ts @@ -0,0 +1,26 @@ +import type { CircuitDeviceRowStructureProjectCommand } from "../models/circuit-device-row-structure-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteCircuitDeviceRowStructureCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: CircuitDeviceRowStructureProjectCommand; +} + +export interface ExecutedCircuitDeviceRowStructureCommand { + revision: AppendedProjectRevision; + inverse: CircuitDeviceRowStructureProjectCommand; +} + +export interface CircuitDeviceRowStructureProjectCommandStore { + execute( + input: ExecuteCircuitDeviceRowStructureCommandInput + ): ExecutedCircuitDeviceRowStructureCommand; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index cd61c4d..0d8d7c3 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -3,6 +3,12 @@ import { assertCircuitDeviceRowUpdateProjectCommand, circuitDeviceRowUpdateCommandType, } from "../models/circuit-device-row-project-command.model.js"; +import { + assertCircuitDeviceRowDeleteProjectCommand, + assertCircuitDeviceRowInsertProjectCommand, + circuitDeviceRowDeleteCommandType, + circuitDeviceRowInsertCommandType, +} from "../models/circuit-device-row-structure-project-command.model.js"; import { assertCircuitUpdateProjectCommand, circuitUpdateCommandType, @@ -12,6 +18,7 @@ import { type SerializedProjectCommand, } from "../models/project-command.model.js"; import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js"; +import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js"; import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js"; import type { ExecuteProjectHistoryCommandInput, @@ -39,6 +46,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { constructor( private readonly circuitStore: CircuitProjectCommandStore, private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore, + private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore, private readonly historyStore: ProjectHistoryStore ) {} @@ -118,6 +126,20 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitDeviceRowInsertCommandType: { + assertCircuitDeviceRowInsertProjectCommand(input.command); + return this.deviceRowStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } + case circuitDeviceRowDeleteCommandType: { + assertCircuitDeviceRowDeleteProjectCommand(input.command); + return this.deviceRowStructureStore.execute({ + ...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 ff19b5d..d81f5c4 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -1,5 +1,6 @@ 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 { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js"; @@ -7,9 +8,12 @@ import { ProjectCommandService } from "../../domain/services/project-command.ser export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); export const circuitDeviceRowProjectCommandStore = new CircuitDeviceRowProjectCommandRepository(db); +export const circuitDeviceRowStructureProjectCommandStore = + new CircuitDeviceRowStructureProjectCommandRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db); export const projectCommandService = new ProjectCommandService( circuitProjectCommandStore, circuitDeviceRowProjectCommandStore, + circuitDeviceRowStructureProjectCommandStore, projectHistoryStore ); diff --git a/tests/circuit-device-row-structure-project-command.repository.test.ts b/tests/circuit-device-row-structure-project-command.repository.test.ts new file mode 100644 index 0000000..b732e32 --- /dev/null +++ b/tests/circuit-device-row-structure-project-command.repository.test.ts @@ -0,0 +1,445 @@ +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 { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-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 { + createCircuitDeviceRowDeleteProjectCommand, + createCircuitDeviceRowInsertProjectCommand, + type CircuitDeviceRowSnapshot, +} from "../src/domain/models/circuit-device-row-structure-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(); + + 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(circuits) + .values([ + { + id: "circuit-1", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F1", + sortOrder: 10, + isReserve: 0, + }, + { + id: "circuit-reserve", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F2", + sortOrder: 20, + isReserve: 1, + }, + { + id: "circuit-foreign", + circuitListId: foreignBoard.id, + sectionId: foreignSection.id, + equipmentIdentifier: "-1F1", + sortOrder: 10, + isReserve: 1, + }, + ]) + .run(); + 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(circuitDeviceRows) + .values({ + id: "row-1", + circuitId: "circuit-1", + linkedProjectDeviceId: "device-1", + legacyConsumerId: "legacy-1", + sortOrder: 10, + name: "Leuchte", + displayName: "Leuchte lokal", + phaseType: "single_phase", + connectionKind: "fixed", + costGroup: "440", + category: "lighting", + level: "EG", + roomId: "room-1", + roomNumberSnapshot: "001", + roomNameSnapshot: "Büro", + quantity: 2, + powerPerUnit: 0.12, + simultaneityFactor: 0.8, + cosPhi: 0.9, + remark: "Bestand", + overriddenFields: "[\"displayName\"]", + }) + .run(); + return context; +} + +function createInsertSnapshot( + overrides: Partial = {} +): CircuitDeviceRowSnapshot { + return { + id: "row-new", + circuitId: "circuit-reserve", + linkedProjectDeviceId: "device-1", + legacyConsumerId: null, + sortOrder: 10, + name: "Steckdose", + displayName: "Steckdose lokal", + phaseType: "single_phase", + connectionKind: "socket", + costGroup: "440", + category: "socket", + level: "EG", + roomId: "room-1", + roomNumberSnapshot: "001", + roomNameSnapshot: "Büro", + quantity: 1, + powerPerUnit: 0.2, + simultaneityFactor: 1, + cosPhi: 0.95, + remark: null, + overriddenFields: null, + ...overrides, + }; +} + +function getIsReserve(context: DatabaseContext, circuitId: string) { + return Boolean( + context.db + .select({ isReserve: circuits.isReserve }) + .from(circuits) + .where(eq(circuits.id, circuitId)) + .get()?.isReserve + ); +} + +describe("circuit device-row structure project-command repository", () => { + it("inserts a stable row and supports persisted undo and redo", () => { + const context = createTestDatabase(); + try { + const store = + new CircuitDeviceRowStructureProjectCommandRepository(context.db); + const command = createCircuitDeviceRowInsertProjectCommand( + createInsertSnapshot() + ); + const inserted = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.equal( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-new")) + .get()?.displayName, + "Steckdose lokal" + ); + assert.equal(getIsReserve(context, "circuit-reserve"), false); + + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command: inserted.inverse, + }); + assert.equal( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-new")) + .get(), + undefined + ); + assert.equal(getIsReserve(context, "circuit-reserve"), true); + + store.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command, + }); + assert.equal( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-new")) + .get()?.id, + "row-new" + ); + assert.deepEqual( + new ProjectHistoryRepository(context.db).getState("project-1"), + { + projectId: "project-1", + currentRevision: 3, + undoDepth: 1, + redoDepth: 0, + undoChangeSetId: inserted.revision.changeSetId, + redoChangeSetId: null, + } + ); + } finally { + context.close(); + } + }); + + it("deletes and restores the complete persisted row snapshot", () => { + const context = createTestDatabase(); + try { + const store = + new CircuitDeviceRowStructureProjectCommandRepository(context.db); + const before = context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get(); + assert.ok(before); + + const deleted = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowDeleteProjectCommand( + "row-1", + "circuit-1" + ), + }); + assert.equal(getIsReserve(context, "circuit-1"), true); + assert.deepEqual(deleted.inverse.payload, { row: before }); + + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: deleted.revision.changeSetId, + command: deleted.inverse, + }); + assert.deepEqual( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get(), + before + ); + assert.equal(getIsReserve(context, "circuit-1"), false); + } finally { + context.close(); + } + }); + + it("rolls back insert and delete when late history persistence fails", () => { + const context = createTestDatabase(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_structure_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced structure history failure'); + END; + `); + const store = + new CircuitDeviceRowStructureProjectCommandRepository(context.db); + + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowInsertProjectCommand( + createInsertSnapshot() + ), + }), + /forced structure history failure/ + ); + assert.equal( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-new")) + .get(), + undefined + ); + assert.equal(getIsReserve(context, "circuit-reserve"), true); + + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowDeleteProjectCommand( + "row-1", + "circuit-1" + ), + }), + /forced structure history failure/ + ); + assert.ok( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get() + ); + assert.equal(getIsReserve(context, "circuit-1"), false); + assert.equal(context.db.select().from(projectRevisions).all().length, 0); + } finally { + context.close(); + } + }); + + it("rolls back a structural write for a stale expected revision", () => { + const context = createTestDatabase(); + try { + const store = + new CircuitDeviceRowStructureProjectCommandRepository(context.db); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createCircuitDeviceRowDeleteProjectCommand( + "row-1", + "circuit-1" + ), + }), + /at revision 0, expected 1/ + ); + assert.ok( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get() + ); + assert.equal(getIsReserve(context, "circuit-1"), false); + assert.equal(context.db.select().from(projectRevisions).all().length, 0); + } finally { + context.close(); + } + }); + + it("rejects foreign ownership, references and duplicate row ids", () => { + const context = createTestDatabase(); + try { + const store = + new CircuitDeviceRowStructureProjectCommandRepository(context.db); + for (const snapshot of [ + createInsertSnapshot({ circuitId: "circuit-foreign" }), + createInsertSnapshot({ + id: "row-foreign-device", + linkedProjectDeviceId: "device-foreign", + }), + createInsertSnapshot({ + id: "row-foreign-room", + roomId: "room-foreign", + }), + createInsertSnapshot({ id: "row-1" }), + createInsertSnapshot({ + id: "row-legacy", + legacyConsumerId: "legacy-user-value", + }), + ]) { + assert.throws(() => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowInsertProjectCommand(snapshot), + }) + ); + } + assert.equal(context.db.select().from(projectRevisions).all().length, 0); + } finally { + context.close(); + } + }); +}); diff --git a/tests/project-command.model.test.ts b/tests/project-command.model.test.ts index f782489..7992e16 100644 --- a/tests/project-command.model.test.ts +++ b/tests/project-command.model.test.ts @@ -13,6 +13,11 @@ import { assertCircuitDeviceRowUpdateProjectCommand, createCircuitDeviceRowUpdateProjectCommand, } from "../src/domain/models/circuit-device-row-project-command.model.js"; +import { + assertCircuitDeviceRowInsertProjectCommand, + createCircuitDeviceRowDeleteProjectCommand, + createCircuitDeviceRowInsertProjectCommand, +} from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; describe("serialized project commands", () => { it("round-trips a versioned command envelope", () => { @@ -166,3 +171,72 @@ describe("circuit device-row update project commands", () => { ); }); }); + +describe("circuit device-row 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: 0.9, + remark: null, + overriddenFields: null, + }; + + it("captures complete insert and delete identities", () => { + const insert = createCircuitDeviceRowInsertProjectCommand(row); + const remove = createCircuitDeviceRowDeleteProjectCommand( + row.id, + row.circuitId + ); + + assert.deepEqual(insert.payload.row, row); + assert.deepEqual(remove.payload, { + rowId: "row-1", + expectedCircuitId: "circuit-1", + }); + }); + + it("rejects incomplete snapshots and invalid values", () => { + assert.throws( + () => + assertCircuitDeviceRowInsertProjectCommand({ + schemaVersion: 1, + type: "circuit-device-row.insert", + payload: { + row: { + ...row, + linkedProjectDeviceId: undefined, + }, + }, + }), + /linkedProjectDeviceId/ + ); + assert.throws( + () => + createCircuitDeviceRowInsertProjectCommand({ + ...row, + quantity: -1, + }), + /must not be negative/ + ); + assert.throws( + () => createCircuitDeviceRowDeleteProjectCommand("", "circuit-1"), + /rowId/ + ); + }); +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index d337237..239dcad 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -8,6 +8,7 @@ import { type DatabaseContext, } from "../src/db/database-context.js"; import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js"; +import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; @@ -20,6 +21,7 @@ import { projects } from "../src/db/schema/projects.js"; import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; +import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; @@ -72,6 +74,7 @@ function createService(context: DatabaseContext) { return new ProjectCommandService( new CircuitProjectCommandRepository(context.db), new CircuitDeviceRowProjectCommandRepository(context.db), + new CircuitDeviceRowStructureProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } @@ -193,6 +196,65 @@ describe("project command service", () => { } }); + it("dispatches device-row insertion and its persisted inverse", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const inserted = service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createCircuitDeviceRowInsertProjectCommand({ + id: "row-2", + circuitId: "circuit-1", + linkedProjectDeviceId: null, + legacyConsumerId: null, + sortOrder: 20, + name: "Steckdose", + displayName: "Steckdose", + phaseType: "single_phase", + connectionKind: null, + costGroup: null, + category: null, + level: null, + roomId: null, + roomNumberSnapshot: null, + roomNameSnapshot: null, + quantity: 1, + powerPerUnit: 0.2, + simultaneityFactor: 1, + cosPhi: null, + remark: null, + overriddenFields: null, + }), + }); + assert.equal(inserted.history.undoDepth, 1); + assert.equal( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-2")) + .get()?.displayName, + "Steckdose" + ); + + const undone = createService(context).undo({ + projectId: "project-1", + expectedRevision: 1, + }); + assert.equal(undone.history.redoDepth, 1); + assert.equal( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-2")) + .get(), + undefined + ); + } finally { + context.close(); + } + }); + it("rejects unavailable history directions without writing a revision", () => { const context = createTestDatabase(); try {