diff --git a/docs/current-architecture.md b/docs/current-architecture.md index a48811c..31977b1 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -69,9 +69,11 @@ und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze -angeschlossen. Für Circuit-Feldänderungen existiert ein erster interner -Command-Store, der Fachänderung, automatisch erzeugtes inverses Kommando und -Revision gemeinsam committen beziehungsweise zurückrollen kann. Er ist noch +angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren interne +Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und +Revision gemeinsam committen beziehungsweise zurückrollen können. +Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und +prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Die Stores sind noch nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo ist daher noch nicht verfügbar. diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index f7e3979..a451217 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -153,14 +153,19 @@ Completed foundation: ownership and equipment-identifier uniqueness inside its transaction - circuit updates derive their inverse from the persisted pre-command row and commit the domain update, inverse command, change set and revision together +- an internal `circuit-device-row.update` command applies the same boundary to + device fields, validates project-device and room ownership and records + automatically derived local override metadata in its normalized forward + command - integration tests apply the generated inverse as a new `undo` revision and - verify rollback for stale revisions and late history failures + verify rollback for stale revisions and late history failures on both update + command types Remaining constraints before implementing history: - extend the concrete project-scoped command union and executors beyond the - internal `circuit.update` slice; the generic versioned command envelope is - complete + internal Circuit and CircuitDeviceRow update slices; the generic versioned + command envelope is complete - route domain writes and revision recording through one shared project transaction boundary; the current revision repository only commits history records and the counter together diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 26e7cbc..7651cc7 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -376,6 +376,9 @@ Implemented foundation: persisted pre-command state - circuit update, inverse command, change set and revision share one SQLite transaction with stale-command and late-failure rollback coverage +- an internal typed `circuit-device-row.update` command uses the same boundary, + records derived ProjectDevice override metadata and rejects cross-project + rows, device links and rooms - 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 b916e1f..39bfb7d 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/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/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/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/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-project-command.repository.ts b/src/db/repositories/circuit-device-row-project-command.repository.ts new file mode 100644 index 0000000..aac15e2 --- /dev/null +++ b/src/db/repositories/circuit-device-row-project-command.repository.ts @@ -0,0 +1,182 @@ +import { and, eq } from "drizzle-orm"; +import { + assertCircuitDeviceRowUpdateProjectCommand, + createCircuitDeviceRowUpdateProjectCommand, + type CircuitDeviceRowUpdateField, + type CircuitDeviceRowUpdatePatch, + type CircuitDeviceRowUpdateValues, +} from "../../domain/models/circuit-device-row-project-command.model.js"; +import type { + CircuitDeviceRowProjectCommandStore, + ExecuteCircuitDeviceRowUpdateCommandInput, +} from "../../domain/ports/circuit-device-row-project-command.store.js"; +import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.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 { + toCircuitDeviceRowPatchValues, + type CircuitDeviceRowPatchInput, +} from "./circuit-device-row.persistence.js"; +import { appendProjectRevision } from "./project-revision.persistence.js"; + +type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect; + +export class CircuitDeviceRowProjectCommandRepository + implements CircuitDeviceRowProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) { + assertCircuitDeviceRowUpdateProjectCommand(input.command); + + return this.database.transaction((tx) => { + const current = tx + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, input.command.payload.rowId)) + .get(); + if (!current) { + throw new Error("Invalid device row id."); + } + + const circuit = tx + .select({ circuitListId: circuits.circuitListId }) + .from(circuits) + .where(eq(circuits.id, current.circuitId)) + .get(); + const owningList = circuit + ? tx + .select({ id: circuitLists.id }) + .from(circuitLists) + .where( + and( + eq(circuitLists.id, circuit.circuitListId), + eq(circuitLists.projectId, input.projectId) + ) + ) + .get() + : null; + if (!owningList) { + throw new Error("Circuit device row does not belong to project."); + } + + const patch = Object.fromEntries( + input.command.payload.changes.map((change) => [ + change.field, + change.value, + ]) + ) as CircuitDeviceRowPatchInput; + this.assertLinkedProjectDevice(tx, input.projectId, patch); + this.assertRoom(tx, input.projectId, patch); + + const overriddenFields = deriveOverriddenFieldsForLocalEdit( + current, + patch + ); + if ( + overriddenFields !== undefined && + patch.overriddenFields === undefined + ) { + patch.overriddenFields = overriddenFields; + } + + const appliedForward = createCircuitDeviceRowUpdateProjectCommand( + current.id, + patch as CircuitDeviceRowUpdatePatch + ); + const inversePatch = Object.fromEntries( + appliedForward.payload.changes.map((change) => [ + change.field, + getCircuitDeviceRowFieldValue(current, change.field), + ]) + ) as CircuitDeviceRowUpdatePatch; + const inverse = createCircuitDeviceRowUpdateProjectCommand( + current.id, + inversePatch + ); + + const update = tx + .update(circuitDeviceRows) + .set(toCircuitDeviceRowPatchValues(patch)) + .where(eq(circuitDeviceRows.id, current.id)) + .run(); + if (update.changes !== 1) { + throw new Error("Circuit device row changed before command execution."); + } + + const revision = appendProjectRevision(tx, { + projectId: input.projectId, + expectedRevision: input.expectedRevision, + source: input.source, + description: input.description, + actorId: input.actorId, + forward: appliedForward, + inverse, + }); + + return { revision, inverse }; + }); + } + + private assertLinkedProjectDevice( + database: AppDatabase, + projectId: string, + patch: CircuitDeviceRowPatchInput + ) { + if ( + !Object.prototype.hasOwnProperty.call(patch, "linkedProjectDeviceId") || + patch.linkedProjectDeviceId === null || + patch.linkedProjectDeviceId === undefined + ) { + return; + } + const device = database + .select({ id: projectDevices.id }) + .from(projectDevices) + .where( + and( + eq(projectDevices.id, patch.linkedProjectDeviceId), + eq(projectDevices.projectId, projectId) + ) + ) + .get(); + if (!device) { + throw new Error("Invalid linked project device id."); + } + } + + private assertRoom( + database: AppDatabase, + projectId: string, + patch: CircuitDeviceRowPatchInput + ) { + if ( + !Object.prototype.hasOwnProperty.call(patch, "roomId") || + patch.roomId === null || + patch.roomId === undefined + ) { + return; + } + const room = database + .select({ id: rooms.id }) + .from(rooms) + .where(and(eq(rooms.id, patch.roomId), eq(rooms.projectId, projectId))) + .get(); + if (!room) { + throw new Error("Invalid room id."); + } + } +} + +function getCircuitDeviceRowFieldValue< + TField extends CircuitDeviceRowUpdateField, +>( + row: CircuitDeviceRow, + field: TField +): CircuitDeviceRowUpdateValues[TField] { + return row[field] as unknown as CircuitDeviceRowUpdateValues[TField]; +} diff --git a/src/db/repositories/circuit-device-row.persistence.ts b/src/db/repositories/circuit-device-row.persistence.ts index 2a6fc44..47ae7ee 100644 --- a/src/db/repositories/circuit-device-row.persistence.ts +++ b/src/db/repositories/circuit-device-row.persistence.ts @@ -20,9 +20,26 @@ export interface CircuitDeviceRowUpdateInput { overriddenFields?: string; } -export type CircuitDeviceRowPatchInput = Partial & { +export interface CircuitDeviceRowPatchInput { + linkedProjectDeviceId?: 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 CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput { circuitId: string; diff --git a/src/domain/models/circuit-device-row-project-command.model.ts b/src/domain/models/circuit-device-row-project-command.model.ts new file mode 100644 index 0000000..5e79d04 --- /dev/null +++ b/src/domain/models/circuit-device-row-project-command.model.ts @@ -0,0 +1,177 @@ +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const circuitDeviceRowUpdateCommandType = + "circuit-device-row.update" as const; +export const circuitDeviceRowUpdateCommandSchemaVersion = 1 as const; + +export interface CircuitDeviceRowUpdateValues { + linkedProjectDeviceId: 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 type CircuitDeviceRowUpdateField = keyof CircuitDeviceRowUpdateValues; +export type CircuitDeviceRowUpdatePatch = + Partial; + +export interface CircuitDeviceRowUpdateFieldChange< + TField extends CircuitDeviceRowUpdateField = CircuitDeviceRowUpdateField, +> { + field: TField; + value: CircuitDeviceRowUpdateValues[TField]; +} + +export interface CircuitDeviceRowUpdateCommandPayload { + rowId: string; + changes: CircuitDeviceRowUpdateFieldChange[]; +} + +export interface CircuitDeviceRowUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitDeviceRowUpdateCommandSchemaVersion; + type: typeof circuitDeviceRowUpdateCommandType; +} + +export function createCircuitDeviceRowUpdateProjectCommand( + rowId: string, + patch: CircuitDeviceRowUpdatePatch +): CircuitDeviceRowUpdateProjectCommand { + const changes = Object.entries(patch).map(([field, value]) => ({ + field: field as CircuitDeviceRowUpdateField, + value, + })) as CircuitDeviceRowUpdateFieldChange[]; + const command: CircuitDeviceRowUpdateProjectCommand = { + schemaVersion: circuitDeviceRowUpdateCommandSchemaVersion, + type: circuitDeviceRowUpdateCommandType, + payload: { rowId, changes }, + }; + assertCircuitDeviceRowUpdateProjectCommand(command); + return command; +} + +export function assertCircuitDeviceRowUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitDeviceRowUpdateProjectCommand { + if ( + command.schemaVersion !== circuitDeviceRowUpdateCommandSchemaVersion || + command.type !== circuitDeviceRowUpdateCommandType + ) { + throw new Error("Unsupported circuit device-row update command."); + } + if (!isPlainObject(command.payload)) { + throw new Error("Circuit device-row update payload must be an object."); + } + const rowId = command.payload.rowId; + const changes = command.payload.changes; + if (typeof rowId !== "string" || !rowId.trim()) { + throw new Error("Circuit device-row update command requires a row id."); + } + if (!Array.isArray(changes) || changes.length === 0) { + throw new Error( + "Circuit device-row 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( + "Circuit device-row update command contains an invalid change." + ); + } + if ( + !isCircuitDeviceRowUpdateField(change.field) || + seenFields.has(change.field) + ) { + throw new Error( + "Circuit device-row update command contains an invalid or duplicate field." + ); + } + assertCircuitDeviceRowUpdateFieldValue(change.field, change.value); + seenFields.add(change.field); + } +} + +function assertCircuitDeviceRowUpdateFieldValue( + field: CircuitDeviceRowUpdateField, + 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 === "sortOrder") { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error("sortOrder must be a finite number."); + } + return; + } + if ( + field === "quantity" || + field === "powerPerUnit" || + field === "simultaneityFactor" + ) { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new Error(`${field} must be a non-negative finite number.`); + } + return; + } + if (field === "cosPhi") { + if ( + value !== null && + (typeof value !== "number" || !Number.isFinite(value) || value <= 0) + ) { + throw new Error("cosPhi must be a positive finite number or null."); + } + return; + } + if (value !== null && typeof value !== "string") { + throw new Error(`${field} must be a string or null.`); + } +} + +function isCircuitDeviceRowUpdateField( + value: string +): value is CircuitDeviceRowUpdateField { + return [ + "linkedProjectDeviceId", + "sortOrder", + "name", + "displayName", + "phaseType", + "connectionKind", + "costGroup", + "category", + "level", + "roomId", + "roomNumberSnapshot", + "roomNameSnapshot", + "quantity", + "powerPerUnit", + "simultaneityFactor", + "cosPhi", + "remark", + "overriddenFields", + ].includes(value); +} + +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-project-command.store.ts b/src/domain/ports/circuit-device-row-project-command.store.ts new file mode 100644 index 0000000..125693d --- /dev/null +++ b/src/domain/ports/circuit-device-row-project-command.store.ts @@ -0,0 +1,25 @@ +import type { CircuitDeviceRowUpdateProjectCommand } from "../models/circuit-device-row-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteCircuitDeviceRowUpdateCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + command: CircuitDeviceRowUpdateProjectCommand; +} + +export interface ExecutedCircuitDeviceRowUpdateCommand { + revision: AppendedProjectRevision; + inverse: CircuitDeviceRowUpdateProjectCommand; +} + +export interface CircuitDeviceRowProjectCommandStore { + executeUpdate( + input: ExecuteCircuitDeviceRowUpdateCommandInput + ): ExecutedCircuitDeviceRowUpdateCommand; +} diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index a83cccc..9b72dde 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -17,11 +17,7 @@ import type { UpdateCircuitInput, } from "../../shared/validation/circuit.schemas.js"; import { CircuitNumberingService } from "./circuit-numbering.service.js"; -import { projectDeviceSyncFields } from "../../shared/constants/project-device-sync-fields.js"; -import { - parseOverriddenFields, - serializeOverriddenFields, -} from "./project-device-sync.service.js"; +import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js"; export class CircuitWriteService { private readonly circuitRepository: CircuitRepository; @@ -256,21 +252,10 @@ export class CircuitWriteService { throw new Error("Invalid device row id."); } await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId); - let overriddenFields = input.overriddenFields; - if (current.linkedProjectDeviceId && input.overriddenFields === undefined) { - const overrides = new Set(parseOverriddenFields(current.overriddenFields)); - const inputValues = input as Record; - const currentValues = current as unknown as Record; - for (const field of projectDeviceSyncFields) { - if ( - Object.prototype.hasOwnProperty.call(input, field) && - (inputValues[field] ?? null) !== (currentValues[field] ?? null) - ) { - overrides.add(field); - } - } - overriddenFields = serializeOverriddenFields(overrides); - } + const overriddenFields = deriveOverriddenFieldsForLocalEdit( + current, + input + ); await this.deviceRowRepository.updateFields(rowId, { ...input, ...(overriddenFields !== undefined ? { overriddenFields } : {}), diff --git a/src/domain/services/project-device-overrides.ts b/src/domain/services/project-device-overrides.ts new file mode 100644 index 0000000..9e292ff --- /dev/null +++ b/src/domain/services/project-device-overrides.ts @@ -0,0 +1,57 @@ +import { + projectDeviceSyncFields, + type ProjectDeviceSyncField, +} from "../../shared/constants/project-device-sync-fields.js"; + +export function parseOverriddenFields( + value: string | null | undefined +): ProjectDeviceSyncField[] { + if (!value) { + return []; + } + try { + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter((field): field is ProjectDeviceSyncField => + projectDeviceSyncFields.includes(field as ProjectDeviceSyncField) + ); + } catch { + return []; + } +} + +export function serializeOverriddenFields( + fields: Iterable +): string | undefined { + const fieldSet = new Set(fields); + const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field)); + return unique.length > 0 ? JSON.stringify(unique) : undefined; +} + +export function deriveOverriddenFieldsForLocalEdit( + current: { + linkedProjectDeviceId: string | null | undefined; + overriddenFields: string | null | undefined; + } & Partial>, + patch: Partial> +): string | null | undefined { + if ( + !current.linkedProjectDeviceId || + Object.prototype.hasOwnProperty.call(patch, "overriddenFields") + ) { + return patch.overriddenFields as string | null | undefined; + } + + const overrides = new Set(parseOverriddenFields(current.overriddenFields)); + for (const field of projectDeviceSyncFields) { + if ( + Object.prototype.hasOwnProperty.call(patch, field) && + (patch[field] ?? null) !== (current[field] ?? null) + ) { + overrides.add(field); + } + } + return serializeOverriddenFields(overrides); +} diff --git a/src/domain/services/project-device-sync.service.ts b/src/domain/services/project-device-sync.service.ts index 5cf4e26..095386e 100644 --- a/src/domain/services/project-device-sync.service.ts +++ b/src/domain/services/project-device-sync.service.ts @@ -5,6 +5,15 @@ import { projectDeviceSyncFields, type ProjectDeviceSyncField, } from "../../shared/constants/project-device-sync-fields.js"; +import { + parseOverriddenFields, + serializeOverriddenFields, +} from "./project-device-overrides.js"; + +export { + parseOverriddenFields, + serializeOverriddenFields, +} from "./project-device-overrides.js"; type ProjectDevice = NonNullable>>; type LinkedRow = Awaited>[number]; @@ -25,29 +34,6 @@ export interface ProjectDeviceSyncRestoreRow { overriddenFields: ProjectDeviceSyncField[]; } -export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] { - if (!value) { - return []; - } - try { - const parsed: unknown = JSON.parse(value); - if (!Array.isArray(parsed)) { - return []; - } - return parsed.filter((field): field is ProjectDeviceSyncField => - projectDeviceSyncFields.includes(field as ProjectDeviceSyncField) - ); - } catch { - return []; - } -} - -export function serializeOverriddenFields(fields: Iterable): string | undefined { - const fieldSet = new Set(fields); - const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field)); - return unique.length > 0 ? JSON.stringify(unique) : undefined; -} - function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) { return projectDevice[field]; } diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 4ffc057..e64135a 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -1,4 +1,7 @@ import { db } from "../../db/client.js"; +import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js"; export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); +export const circuitDeviceRowProjectCommandStore = + new CircuitDeviceRowProjectCommandRepository(db); diff --git a/tests/circuit-device-row-project-command.repository.test.ts b/tests/circuit-device-row-project-command.repository.test.ts new file mode 100644 index 0000000..19444fc --- /dev/null +++ b/tests/circuit-device-row-project-command.repository.test.ts @@ -0,0 +1,379 @@ +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 { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js"; +import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.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 { projectChangeSets } from "../src/db/schema/project-change-sets.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 { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; +import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; +import { deserializeProjectCommand } from "../src/domain/models/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, + }, + { + id: "circuit-2", + circuitListId: foreignBoard.id, + sectionId: foreignSection.id, + equipmentIdentifier: "-1F1", + sortOrder: 10, + }, + ]) + .run(); + context.db + .insert(projectDevices) + .values([ + { + id: "project-device-1", + projectId: "project-1", + name: "Leuchte", + displayName: "Leuchte", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }, + { + id: "project-device-2", + 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", + floorId: null, + roomNumber: "001", + roomName: "Büro", + }, + { + id: "room-2", + projectId: "project-2", + floorId: null, + roomNumber: "999", + roomName: "Fremdraum", + }, + ]) + .run(); + context.db + .insert(circuitDeviceRows) + .values([ + { + id: "row-1", + circuitId: "circuit-1", + linkedProjectDeviceId: "project-device-1", + sortOrder: 10, + name: "Leuchte", + displayName: "Leuchte lokal", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }, + { + id: "row-2", + circuitId: "circuit-2", + sortOrder: 10, + name: "Fremdzeile", + displayName: "Fremdzeile", + quantity: 1, + powerPerUnit: 0.2, + simultaneityFactor: 1, + }, + ]) + .run(); + return context; +} + +function getRow(context: DatabaseContext) { + const row = context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get(); + assert.ok(row); + return row; +} + +describe("circuit device-row project-command repository", () => { + it("commits the row update, tracked override, inverse and revision together", () => { + const context = createTestDatabase(); + try { + const store = new CircuitDeviceRowProjectCommandRepository(context.db); + const executed = store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + description: "Gerätezeile bearbeiten", + command: createCircuitDeviceRowUpdateProjectCommand("row-1", { + quantity: 2, + roomId: "room-1", + remark: null, + }), + }); + + const row = getRow(context); + assert.equal(row.quantity, 2); + assert.equal(row.roomId, "room-1"); + assert.equal(row.remark, null); + assert.equal(row.overriddenFields, "[\"quantity\"]"); + assert.equal(executed.revision.revisionNumber, 1); + assert.deepEqual(executed.inverse.payload, { + rowId: "row-1", + changes: [ + { field: "quantity", value: 1 }, + { field: "roomId", value: null }, + { field: "remark", value: null }, + { field: "overriddenFields", value: null }, + ], + }); + + const changeSet = context.db.select().from(projectChangeSets).get(); + assert.ok(changeSet); + const storedForward = deserializeProjectCommand( + changeSet.forwardPayloadJson + ); + assert.deepEqual(storedForward, { + schemaVersion: 1, + type: "circuit-device-row.update", + payload: { + rowId: "row-1", + changes: [ + { field: "quantity", value: 2 }, + { field: "roomId", value: "room-1" }, + { field: "remark", value: null }, + { field: "overriddenFields", value: "[\"quantity\"]" }, + ], + }, + }); + } finally { + context.close(); + } + }); + + it("applies the generated inverse and restores override metadata", () => { + const context = createTestDatabase(); + try { + const store = new CircuitDeviceRowProjectCommandRepository(context.db); + const forward = store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowUpdateProjectCommand("row-1", { + quantity: 3, + }), + }); + const undone = store.executeUpdate({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + command: forward.inverse, + }); + + const row = getRow(context); + assert.equal(row.quantity, 1); + assert.equal(row.overriddenFields, null); + assert.equal(undone.revision.revisionNumber, 2); + assert.deepEqual( + context.db + .select({ source: projectRevisions.source }) + .from(projectRevisions) + .all() + .map((revision) => revision.source), + ["user", "undo"] + ); + } finally { + context.close(); + } + }); + + it("rolls back the row and override update for a stale revision", () => { + const context = createTestDatabase(); + try { + const store = new CircuitDeviceRowProjectCommandRepository(context.db); + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowUpdateProjectCommand("row-1", { + quantity: 2, + }), + }); + + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowUpdateProjectCommand("row-1", { + quantity: 4, + }), + }), + (error) => + error instanceof ProjectRevisionConflictError && + error.actualRevision === 1 + ); + + const row = getRow(context); + assert.equal(row.quantity, 2); + assert.equal(row.overriddenFields, "[\"quantity\"]"); + assert.equal(context.db.select().from(projectRevisions).all().length, 1); + } finally { + context.close(); + } + }); + + it("rolls back the row update when late history persistence fails", () => { + const context = createTestDatabase(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_device_row_command_change_set + BEFORE INSERT ON project_change_sets + BEGIN + SELECT RAISE(ABORT, 'forced device-row history failure'); + END; + `); + const store = new CircuitDeviceRowProjectCommandRepository(context.db); + + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowUpdateProjectCommand("row-1", { + quantity: 5, + }), + }), + /forced device-row history failure/ + ); + + const row = getRow(context); + assert.equal(row.quantity, 1); + assert.equal(row.overriddenFields, null); + assert.equal( + context.db + .select() + .from(projects) + .where(eq(projects.id, "project-1")) + .get()?.currentRevision, + 0 + ); + assert.equal(context.db.select().from(projectChangeSets).all().length, 0); + } finally { + context.close(); + } + }); + + it("rejects cross-project rows, devices and rooms without history", () => { + const context = createTestDatabase(); + try { + const store = new CircuitDeviceRowProjectCommandRepository(context.db); + + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowUpdateProjectCommand("row-2", { + quantity: 2, + }), + }), + /does not belong to project/ + ); + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowUpdateProjectCommand("row-1", { + linkedProjectDeviceId: "project-device-2", + }), + }), + /Invalid linked project device/ + ); + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowUpdateProjectCommand("row-1", { + roomId: "room-2", + }), + }), + /Invalid room/ + ); + + assert.equal(getRow(context).linkedProjectDeviceId, "project-device-1"); + assert.equal(getRow(context).roomId, null); + 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 2929fc2..f782489 100644 --- a/tests/project-command.model.test.ts +++ b/tests/project-command.model.test.ts @@ -9,6 +9,10 @@ import { assertCircuitUpdateProjectCommand, createCircuitUpdateProjectCommand, } from "../src/domain/models/circuit-project-command.model.js"; +import { + assertCircuitDeviceRowUpdateProjectCommand, + createCircuitDeviceRowUpdateProjectCommand, +} from "../src/domain/models/circuit-device-row-project-command.model.js"; describe("serialized project commands", () => { it("round-trips a versioned command envelope", () => { @@ -122,3 +126,43 @@ describe("circuit update project commands", () => { ); }); }); + +describe("circuit device-row update project commands", () => { + it("creates typed nullable device-row changes", () => { + const command = createCircuitDeviceRowUpdateProjectCommand("row-1", { + roomId: null, + quantity: 2, + cosPhi: 0.9, + }); + + assert.doesNotThrow(() => + assertCircuitDeviceRowUpdateProjectCommand(command) + ); + assert.deepEqual(command.payload.changes, [ + { field: "roomId", value: null }, + { field: "quantity", value: 2 }, + { field: "cosPhi", value: 0.9 }, + ]); + }); + + it("rejects invalid device-row values", () => { + assert.throws( + () => createCircuitDeviceRowUpdateProjectCommand("row-1", {}), + /at least one change/ + ); + assert.throws( + () => + createCircuitDeviceRowUpdateProjectCommand("row-1", { + displayName: "", + }), + /non-empty string/ + ); + assert.throws( + () => + createCircuitDeviceRowUpdateProjectCommand("row-1", { + quantity: -1, + }), + /non-negative/ + ); + }); +});