diff --git a/docs/current-architecture.md b/docs/current-architecture.md index b596295..a48811c 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -68,8 +68,12 @@ Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch 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 an diese Grenze angeschlossen; -serverseitiges Undo/Redo ist daher noch nicht verfügbar. +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 +nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo ist daher +noch nicht verfügbar. ## Projektgeräte diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 6655669..f7e3979 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -149,11 +149,18 @@ Completed foundation: history records atomically - revision appends use an expected-revision compare-and-set and reject stale callers without writing partial history +- a first internal `circuit.update` command validates project/list/section + 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 +- integration tests apply the generated inverse as a new `undo` revision and + verify rollback for stale revisions and late history failures Remaining constraints before implementing history: -- define concrete project-scoped command unions and executors independently - from React callbacks; the generic versioned command envelope is complete +- extend the concrete project-scoped command union and executors beyond the + internal `circuit.update` slice; 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 6475487..26e7cbc 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -372,6 +372,10 @@ Implemented foundation: - each revision has one separately stored logical change set - forward and inverse changes use complete, versioned JSON command envelopes - invalid or lossy command payloads are rejected before persistence +- an internal typed `circuit.update` command generates its inverse from the + persisted pre-command state +- circuit update, inverse command, change set and revision share one SQLite + transaction with stale-command and late-failure rollback coverage - 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 d7fd5ce..b916e1f 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/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/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/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", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/repositories/circuit-project-command.repository.ts b/src/db/repositories/circuit-project-command.repository.ts new file mode 100644 index 0000000..46f4341 --- /dev/null +++ b/src/db/repositories/circuit-project-command.repository.ts @@ -0,0 +1,163 @@ +import { and, eq, ne } from "drizzle-orm"; +import { + assertCircuitUpdateProjectCommand, + createCircuitUpdateProjectCommand, + type CircuitUpdateField, + type CircuitUpdatePatch, + type CircuitUpdateValues, +} from "../../domain/models/circuit-project-command.model.js"; +import type { + CircuitProjectCommandStore, + ExecuteCircuitUpdateCommandInput, +} from "../../domain/ports/circuit-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitSections } from "../schema/circuit-sections.js"; +import { circuits } from "../schema/circuits.js"; +import { + toCircuitPatchValues, + type CircuitPatchPersistenceInput, +} from "./circuit.persistence.js"; +import { appendProjectRevision } from "./project-revision.persistence.js"; + +type CircuitRow = typeof circuits.$inferSelect; + +export class CircuitProjectCommandRepository + implements CircuitProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + executeUpdate(input: ExecuteCircuitUpdateCommandInput) { + assertCircuitUpdateProjectCommand(input.command); + + return this.database.transaction((tx) => { + const current = tx + .select() + .from(circuits) + .where(eq(circuits.id, input.command.payload.circuitId)) + .get(); + if (!current) { + throw new Error("Invalid circuit id."); + } + + const owningList = tx + .select({ id: circuitLists.id }) + .from(circuitLists) + .where( + and( + eq(circuitLists.id, current.circuitListId), + eq(circuitLists.projectId, input.projectId) + ) + ) + .get(); + if (!owningList) { + throw new Error("Circuit does not belong to project."); + } + + const patch = Object.fromEntries( + input.command.payload.changes.map((change) => [ + change.field, + change.value, + ]) + ) as CircuitPatchPersistenceInput; + this.assertSectionInCircuitList(tx, current, patch.sectionId); + this.assertUniqueEquipmentIdentifier( + tx, + current, + patch.equipmentIdentifier + ); + + const inversePatch = Object.fromEntries( + input.command.payload.changes.map((change) => [ + change.field, + getCircuitFieldValue(current, change.field), + ]) + ) as CircuitUpdatePatch; + const inverse = createCircuitUpdateProjectCommand( + current.id, + inversePatch + ); + + const update = tx + .update(circuits) + .set(toCircuitPatchValues(patch)) + .where(eq(circuits.id, current.id)) + .run(); + if (update.changes !== 1) { + throw new Error("Circuit 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, + }); + + return { revision, inverse }; + }); + } + + private assertSectionInCircuitList( + database: AppDatabase, + circuit: CircuitRow, + sectionId: string | undefined + ) { + if (sectionId === undefined || sectionId === circuit.sectionId) { + return; + } + const section = database + .select({ id: circuitSections.id }) + .from(circuitSections) + .where( + and( + eq(circuitSections.id, sectionId), + eq(circuitSections.circuitListId, circuit.circuitListId) + ) + ) + .get(); + if (!section) { + throw new Error("Section does not belong to circuit list."); + } + } + + private assertUniqueEquipmentIdentifier( + database: AppDatabase, + circuit: CircuitRow, + equipmentIdentifier: string | undefined + ) { + if ( + equipmentIdentifier === undefined || + equipmentIdentifier === circuit.equipmentIdentifier + ) { + return; + } + const duplicate = database + .select({ id: circuits.id }) + .from(circuits) + .where( + and( + eq(circuits.circuitListId, circuit.circuitListId), + eq(circuits.equipmentIdentifier, equipmentIdentifier), + ne(circuits.id, circuit.id) + ) + ) + .get(); + if (duplicate) { + throw new Error("Duplicate equipmentIdentifier in circuit list."); + } + } +} + +function getCircuitFieldValue( + circuit: CircuitRow, + field: TField +): CircuitUpdateValues[TField] { + if (field === "isReserve") { + return Boolean(circuit.isReserve) as CircuitUpdateValues[TField]; + } + return circuit[field] as unknown as CircuitUpdateValues[TField]; +} diff --git a/src/db/repositories/circuit.persistence.ts b/src/db/repositories/circuit.persistence.ts index d1b4bb6..f14bc2c 100644 --- a/src/db/repositories/circuit.persistence.ts +++ b/src/db/repositories/circuit.persistence.ts @@ -19,6 +19,26 @@ export interface CircuitCreatePersistenceInput { isReserve?: boolean; } +export interface CircuitPatchPersistenceInput { + sectionId?: string; + equipmentIdentifier?: string; + displayName?: string | null; + sortOrder?: number; + protectionType?: string | null; + protectionRatedCurrent?: number | null; + protectionCharacteristic?: string | null; + cableType?: string | null; + cableCrossSection?: string | null; + cableLength?: number | null; + voltage?: number | null; + controlRequirement?: string | null; + remark?: string | null; + rcdAssignment?: string | null; + terminalDesignation?: string | null; + status?: string | null; + isReserve?: boolean; +} + export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) { return { id, @@ -42,3 +62,41 @@ export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenc remark: input.remark ?? null, }; } + +export function toCircuitPatchValues(input: CircuitPatchPersistenceInput) { + const values: Partial> = {}; + const has = (field: keyof CircuitPatchPersistenceInput) => + Object.prototype.hasOwnProperty.call(input, field); + + if (input.sectionId !== undefined) values.sectionId = input.sectionId; + if (input.equipmentIdentifier !== undefined) { + values.equipmentIdentifier = input.equipmentIdentifier; + } + if (has("displayName")) values.displayName = input.displayName ?? null; + if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder; + if (has("protectionType")) values.protectionType = input.protectionType ?? null; + if (has("protectionRatedCurrent")) { + values.protectionRatedCurrent = input.protectionRatedCurrent ?? null; + } + if (has("protectionCharacteristic")) { + values.protectionCharacteristic = input.protectionCharacteristic ?? null; + } + if (has("cableType")) values.cableType = input.cableType ?? null; + if (has("cableCrossSection")) { + values.cableCrossSection = input.cableCrossSection ?? null; + } + if (has("cableLength")) values.cableLength = input.cableLength ?? null; + if (has("rcdAssignment")) values.rcdAssignment = input.rcdAssignment ?? null; + if (has("terminalDesignation")) { + values.terminalDesignation = input.terminalDesignation ?? null; + } + if (has("voltage")) values.voltage = input.voltage ?? null; + if (has("controlRequirement")) { + values.controlRequirement = input.controlRequirement ?? null; + } + if (has("status")) values.status = input.status ?? null; + if (input.isReserve !== undefined) values.isReserve = input.isReserve ? 1 : 0; + if (has("remark")) values.remark = input.remark ?? null; + + return values; +} diff --git a/src/db/repositories/circuit.repository.ts b/src/db/repositories/circuit.repository.ts index d7eef0d..627d0d6 100644 --- a/src/db/repositories/circuit.repository.ts +++ b/src/db/repositories/circuit.repository.ts @@ -4,67 +4,19 @@ import { db } from "../client.js"; import { circuits } from "../schema/circuits.js"; import { toCircuitCreateValues, + toCircuitPatchValues, type CircuitCreatePersistenceInput, + type CircuitPatchPersistenceInput, } from "./circuit.persistence.js"; -export type { CircuitCreatePersistenceInput } from "./circuit.persistence.js"; -export { toCircuitCreateValues } from "./circuit.persistence.js"; - -export interface CircuitPatchPersistenceInput { - sectionId?: string; - equipmentIdentifier?: string; - displayName?: string; - sortOrder?: number; - protectionType?: string; - protectionRatedCurrent?: number; - protectionCharacteristic?: string; - cableType?: string; - cableCrossSection?: string; - cableLength?: number; - voltage?: number; - controlRequirement?: string; - remark?: string; - rcdAssignment?: string; - terminalDesignation?: string; - status?: string; - isReserve?: boolean; -} - -function toCircuitPatchValues(input: CircuitPatchPersistenceInput) { - const values: Partial = {}; - const has = (field: keyof CircuitPatchPersistenceInput) => - Object.prototype.hasOwnProperty.call(input, field); - - if (input.sectionId !== undefined) values.sectionId = input.sectionId; - if (input.equipmentIdentifier !== undefined) { - values.equipmentIdentifier = input.equipmentIdentifier; - } - if (has("displayName")) values.displayName = input.displayName ?? null; - if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder; - if (has("protectionType")) values.protectionType = input.protectionType ?? null; - if (has("protectionRatedCurrent")) { - values.protectionRatedCurrent = input.protectionRatedCurrent ?? null; - } - if (has("protectionCharacteristic")) { - values.protectionCharacteristic = input.protectionCharacteristic ?? null; - } - if (has("cableType")) values.cableType = input.cableType ?? null; - if (has("cableCrossSection")) values.cableCrossSection = input.cableCrossSection ?? null; - if (has("cableLength")) values.cableLength = input.cableLength ?? null; - if (has("rcdAssignment")) values.rcdAssignment = input.rcdAssignment ?? null; - if (has("terminalDesignation")) { - values.terminalDesignation = input.terminalDesignation ?? null; - } - if (has("voltage")) values.voltage = input.voltage ?? null; - if (has("controlRequirement")) { - values.controlRequirement = input.controlRequirement ?? null; - } - if (has("status")) values.status = input.status ?? null; - if (input.isReserve !== undefined) values.isReserve = input.isReserve ? 1 : 0; - if (has("remark")) values.remark = input.remark ?? null; - - return values; -} +export type { + CircuitCreatePersistenceInput, + CircuitPatchPersistenceInput, +} from "./circuit.persistence.js"; +export { + toCircuitCreateValues, + toCircuitPatchValues, +} from "./circuit.persistence.js"; export class CircuitRepository { async findById(circuitId: string) { diff --git a/src/db/repositories/project-revision.persistence.ts b/src/db/repositories/project-revision.persistence.ts new file mode 100644 index 0000000..05ee246 --- /dev/null +++ b/src/db/repositories/project-revision.persistence.ts @@ -0,0 +1,94 @@ +import crypto from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js"; +import { serializeProjectCommand } from "../../domain/models/project-command.model.js"; +import type { + AppendProjectRevisionInput, + AppendedProjectRevision, +} from "../../domain/ports/project-revision.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { projectChangeSets } from "../schema/project-change-sets.js"; +import { projectRevisions } from "../schema/project-revisions.js"; +import { projects } from "../schema/projects.js"; + +export function appendProjectRevision( + database: AppDatabase, + input: AppendProjectRevisionInput +): AppendedProjectRevision { + validateInput(input); + + const forwardPayloadJson = serializeProjectCommand(input.forward); + const inversePayloadJson = serializeProjectCommand(input.inverse); + const revisionId = crypto.randomUUID(); + const changeSetId = crypto.randomUUID(); + const revisionNumber = input.expectedRevision + 1; + const createdAtIso = new Date().toISOString(); + + const revisionUpdate = database + .update(projects) + .set({ currentRevision: revisionNumber }) + .where( + and( + eq(projects.id, input.projectId), + eq(projects.currentRevision, input.expectedRevision) + ) + ) + .run(); + + if (revisionUpdate.changes !== 1) { + const current = database + .select({ currentRevision: projects.currentRevision }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + throw new ProjectRevisionConflictError( + input.projectId, + input.expectedRevision, + current?.currentRevision ?? null + ); + } + + database + .insert(projectRevisions) + .values({ + id: revisionId, + projectId: input.projectId, + revisionNumber, + createdAtIso, + actorId: input.actorId, + source: input.source, + description: input.description, + }) + .run(); + + database + .insert(projectChangeSets) + .values({ + id: changeSetId, + projectRevisionId: revisionId, + commandType: input.forward.type, + payloadSchemaVersion: input.forward.schemaVersion, + forwardPayloadJson, + inversePayloadJson, + }) + .run(); + + return { + revisionId, + changeSetId, + projectId: input.projectId, + revisionNumber, + createdAtIso, + }; +} + +function validateInput(input: AppendProjectRevisionInput) { + if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) { + throw new Error("Expected project revision must be a non-negative integer."); + } + if (input.forward.schemaVersion !== input.inverse.schemaVersion) { + throw new Error( + "Forward and inverse project commands require the same schema version." + ); + } +} diff --git a/src/db/repositories/project-revision.repository.ts b/src/db/repositories/project-revision.repository.ts index 4d5236d..0fd6539 100644 --- a/src/db/repositories/project-revision.repository.ts +++ b/src/db/repositories/project-revision.repository.ts @@ -1,30 +1,15 @@ -import crypto from "node:crypto"; -import { and, eq } from "drizzle-orm"; -import { serializeProjectCommand } from "../../domain/models/project-command.model.js"; +import { eq } from "drizzle-orm"; +import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js"; import type { AppendProjectRevisionInput, AppendedProjectRevision, ProjectRevisionStore, } from "../../domain/ports/project-revision.store.js"; import type { AppDatabase } from "../database-context.js"; -import { projectChangeSets } from "../schema/project-change-sets.js"; -import { projectRevisions } from "../schema/project-revisions.js"; import { projects } from "../schema/projects.js"; +import { appendProjectRevision } from "./project-revision.persistence.js"; -export class ProjectRevisionConflictError extends Error { - constructor( - readonly projectId: string, - readonly expectedRevision: number, - readonly actualRevision: number | null - ) { - super( - actualRevision === null - ? `Project ${projectId} does not exist.` - : `Project ${projectId} is at revision ${actualRevision}, expected ${expectedRevision}.` - ); - this.name = "ProjectRevisionConflictError"; - } -} +export { ProjectRevisionConflictError }; export class ProjectRevisionRepository implements ProjectRevisionStore { constructor(private readonly database: AppDatabase) {} @@ -39,83 +24,8 @@ export class ProjectRevisionRepository implements ProjectRevisionStore { } append(input: AppendProjectRevisionInput): AppendedProjectRevision { - this.validateInput(input); - - const forwardPayloadJson = serializeProjectCommand(input.forward); - const inversePayloadJson = serializeProjectCommand(input.inverse); - const revisionId = crypto.randomUUID(); - const changeSetId = crypto.randomUUID(); - const revisionNumber = input.expectedRevision + 1; - const createdAtIso = new Date().toISOString(); - - return this.database.transaction((tx) => { - const revisionUpdate = tx - .update(projects) - .set({ currentRevision: revisionNumber }) - .where( - and( - eq(projects.id, input.projectId), - eq(projects.currentRevision, input.expectedRevision) - ) - ) - .run(); - - if (revisionUpdate.changes !== 1) { - const current = tx - .select({ currentRevision: projects.currentRevision }) - .from(projects) - .where(eq(projects.id, input.projectId)) - .get(); - throw new ProjectRevisionConflictError( - input.projectId, - input.expectedRevision, - current?.currentRevision ?? null - ); - } - - tx.insert(projectRevisions) - .values({ - id: revisionId, - projectId: input.projectId, - revisionNumber, - createdAtIso, - actorId: input.actorId, - source: input.source, - description: input.description, - }) - .run(); - - tx.insert(projectChangeSets) - .values({ - id: changeSetId, - projectRevisionId: revisionId, - commandType: input.forward.type, - payloadSchemaVersion: input.forward.schemaVersion, - forwardPayloadJson, - inversePayloadJson, - }) - .run(); - - return { - revisionId, - changeSetId, - projectId: input.projectId, - revisionNumber, - createdAtIso, - }; - }); - } - - private validateInput(input: AppendProjectRevisionInput) { - if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) { - throw new Error("Expected project revision must be a non-negative integer."); - } - if ( - input.forward.schemaVersion !== input.inverse.schemaVersion - ) { - throw new Error( - "Forward and inverse project commands require the same schema version." - ); - } + return this.database.transaction((tx) => + appendProjectRevision(tx, input) + ); } } diff --git a/src/domain/errors/project-revision-conflict.error.ts b/src/domain/errors/project-revision-conflict.error.ts new file mode 100644 index 0000000..d8639f3 --- /dev/null +++ b/src/domain/errors/project-revision-conflict.error.ts @@ -0,0 +1,14 @@ +export class ProjectRevisionConflictError extends Error { + constructor( + readonly projectId: string, + readonly expectedRevision: number, + readonly actualRevision: number | null + ) { + super( + actualRevision === null + ? `Project ${projectId} does not exist.` + : `Project ${projectId} is at revision ${actualRevision}, expected ${expectedRevision}.` + ); + this.name = "ProjectRevisionConflictError"; + } +} diff --git a/src/domain/models/circuit-project-command.model.ts b/src/domain/models/circuit-project-command.model.ts new file mode 100644 index 0000000..4cdaaa3 --- /dev/null +++ b/src/domain/models/circuit-project-command.model.ts @@ -0,0 +1,168 @@ +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const circuitUpdateCommandType = "circuit.update" as const; +export const circuitUpdateCommandSchemaVersion = 1 as const; + +export interface CircuitUpdateValues { + sectionId: string; + equipmentIdentifier: string; + displayName: string | null; + sortOrder: number; + protectionType: string | null; + protectionRatedCurrent: number | null; + protectionCharacteristic: string | null; + cableType: string | null; + cableCrossSection: string | null; + cableLength: number | null; + rcdAssignment: string | null; + terminalDesignation: string | null; + voltage: number | null; + controlRequirement: string | null; + status: string | null; + isReserve: boolean; + remark: string | null; +} + +export type CircuitUpdateField = keyof CircuitUpdateValues; +export type CircuitUpdatePatch = Partial; + +export interface CircuitUpdateFieldChange< + TField extends CircuitUpdateField = CircuitUpdateField, +> { + field: TField; + value: CircuitUpdateValues[TField]; +} + +export interface CircuitUpdateCommandPayload { + circuitId: string; + changes: CircuitUpdateFieldChange[]; +} + +export interface CircuitUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitUpdateCommandSchemaVersion; + type: typeof circuitUpdateCommandType; +} + +export function createCircuitUpdateProjectCommand( + circuitId: string, + patch: CircuitUpdatePatch +): CircuitUpdateProjectCommand { + const changes = Object.entries(patch).map(([field, value]) => ({ + field: field as CircuitUpdateField, + value, + })) as CircuitUpdateFieldChange[]; + const command: CircuitUpdateProjectCommand = { + schemaVersion: circuitUpdateCommandSchemaVersion, + type: circuitUpdateCommandType, + payload: { circuitId, changes }, + }; + assertCircuitUpdateProjectCommand(command); + return command; +} + +export function assertCircuitUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitUpdateProjectCommand { + if ( + command.schemaVersion !== circuitUpdateCommandSchemaVersion || + command.type !== circuitUpdateCommandType + ) { + throw new Error("Unsupported circuit update command."); + } + if (!isPlainObject(command.payload)) { + throw new Error("Circuit update payload must be an object."); + } + const circuitId = command.payload.circuitId; + const changes = command.payload.changes; + if (typeof circuitId !== "string" || !circuitId.trim()) { + throw new Error("Circuit update command requires a circuit id."); + } + if (!Array.isArray(changes) || changes.length === 0) { + throw new Error("Circuit 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 update command contains an invalid change."); + } + if (!isCircuitUpdateField(change.field) || seenFields.has(change.field)) { + throw new Error("Circuit update command contains an invalid or duplicate field."); + } + assertCircuitUpdateFieldValue(change.field, change.value); + seenFields.add(change.field); + } +} + +function assertCircuitUpdateFieldValue( + field: CircuitUpdateField, + value: unknown +) { + if (field === "sectionId" || field === "equipmentIdentifier") { + 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 === "isReserve") { + if (typeof value !== "boolean") { + throw new Error("isReserve must be a boolean."); + } + return; + } + if ( + field === "protectionRatedCurrent" || + field === "cableLength" || + field === "voltage" + ) { + if (value === null) { + return; + } + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${field} must be a finite number or null.`); + } + if ( + (field === "voltage" && value <= 0) || + (field !== "voltage" && value < 0) + ) { + throw new Error(`${field} is outside its allowed range.`); + } + return; + } + if (value !== null && typeof value !== "string") { + throw new Error(`${field} must be a string or null.`); + } +} + +function isCircuitUpdateField(value: string): value is CircuitUpdateField { + return [ + "sectionId", + "equipmentIdentifier", + "displayName", + "sortOrder", + "protectionType", + "protectionRatedCurrent", + "protectionCharacteristic", + "cableType", + "cableCrossSection", + "cableLength", + "rcdAssignment", + "terminalDesignation", + "voltage", + "controlRequirement", + "status", + "isReserve", + "remark", + ].includes(value); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/models/project-command.model.ts b/src/domain/models/project-command.model.ts index 1171493..409a6f3 100644 --- a/src/domain/models/project-command.model.ts +++ b/src/domain/models/project-command.model.ts @@ -6,18 +6,22 @@ export type ProjectCommandJsonValue = | ProjectCommandJsonValue[] | { [key: string]: ProjectCommandJsonValue }; -export interface SerializedProjectCommand { +export interface SerializedProjectCommand { schemaVersion: number; type: string; - payload: ProjectCommandJsonValue; + payload: TPayload; } -export function serializeProjectCommand(command: SerializedProjectCommand): string { +export function serializeProjectCommand( + command: SerializedProjectCommand +): string { assertSerializedProjectCommand(command); return JSON.stringify(command); } -export function deserializeProjectCommand(serialized: string): SerializedProjectCommand { +export function deserializeProjectCommand( + serialized: string +): SerializedProjectCommand { const parsed: unknown = JSON.parse(serialized); assertSerializedProjectCommand(parsed); return parsed; diff --git a/src/domain/ports/circuit-project-command.store.ts b/src/domain/ports/circuit-project-command.store.ts new file mode 100644 index 0000000..e69664b --- /dev/null +++ b/src/domain/ports/circuit-project-command.store.ts @@ -0,0 +1,25 @@ +import type { CircuitUpdateProjectCommand } from "../models/circuit-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteCircuitUpdateCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + command: CircuitUpdateProjectCommand; +} + +export interface ExecutedCircuitUpdateCommand { + revision: AppendedProjectRevision; + inverse: CircuitUpdateProjectCommand; +} + +export interface CircuitProjectCommandStore { + executeUpdate( + input: ExecuteCircuitUpdateCommandInput + ): ExecutedCircuitUpdateCommand; +} diff --git a/src/domain/ports/project-revision.store.ts b/src/domain/ports/project-revision.store.ts index c99ad4e..bac2bf7 100644 --- a/src/domain/ports/project-revision.store.ts +++ b/src/domain/ports/project-revision.store.ts @@ -13,8 +13,8 @@ export interface AppendProjectRevisionInput { source: ProjectRevisionSource; description?: string; actorId?: string; - forward: SerializedProjectCommand; - inverse: SerializedProjectCommand; + forward: SerializedProjectCommand; + inverse: SerializedProjectCommand; } export interface AppendedProjectRevision { diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts new file mode 100644 index 0000000..4ffc057 --- /dev/null +++ b/src/server/composition/project-command-stores.ts @@ -0,0 +1,4 @@ +import { db } from "../../db/client.js"; +import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js"; + +export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); diff --git a/tests/circuit-project-command.repository.test.ts b/tests/circuit-project-command.repository.test.ts new file mode 100644 index 0000000..7cf0417 --- /dev/null +++ b/tests/circuit-project-command.repository.test.ts @@ -0,0 +1,316 @@ +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 { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js"; +import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; +import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; +import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { projects } from "../src/db/schema/projects.js"; +import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; +import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; +import { deserializeProjectCommand } from "../src/domain/models/project-command.model.js"; + +interface TestFixture { + context: DatabaseContext; + circuitListId: string; + sectionId: string; + foreignSectionId: string; +} + +function createTestFixture(): TestFixture { + 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 repository = new DistributionBoardRepository(context.db); + const board = repository.createWithCircuitListAndDefaultSections( + "project-1", + "UV-01" + ); + const foreignBoard = repository.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", + displayName: "Licht Bestand", + sortOrder: 10, + protectionRatedCurrent: 10, + isReserve: 0, + }, + { + id: "circuit-2", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F2", + displayName: "Zweiter Stromkreis", + sortOrder: 20, + isReserve: 0, + }, + ]) + .run(); + + return { + context, + circuitListId: board.id, + sectionId: section.id, + foreignSectionId: foreignSection.id, + }; +} + +function getCircuit(context: DatabaseContext) { + const circuit = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .get(); + assert.ok(circuit); + return circuit; +} + +describe("circuit project-command repository", () => { + it("commits a circuit update, inverse command and revision together", () => { + const fixture = createTestFixture(); + try { + const store = new CircuitProjectCommandRepository(fixture.context.db); + const command = createCircuitUpdateProjectCommand("circuit-1", { + displayName: null, + protectionRatedCurrent: 16, + isReserve: true, + }); + + const executed = store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + description: "Stromkreis bearbeiten", + command, + }); + + const circuit = getCircuit(fixture.context); + assert.equal(circuit.displayName, null); + assert.equal(circuit.protectionRatedCurrent, 16); + assert.equal(circuit.isReserve, 1); + assert.equal(executed.revision.revisionNumber, 1); + assert.deepEqual(executed.inverse.payload, { + circuitId: "circuit-1", + changes: [ + { field: "displayName", value: "Licht Bestand" }, + { field: "protectionRatedCurrent", value: 10 }, + { field: "isReserve", value: false }, + ], + }); + + const changeSet = fixture.context.db + .select() + .from(projectChangeSets) + .get(); + assert.ok(changeSet); + assert.deepEqual(deserializeProjectCommand(changeSet.forwardPayloadJson), command); + assert.deepEqual( + deserializeProjectCommand(changeSet.inversePayloadJson), + executed.inverse + ); + assert.equal( + fixture.context.db + .select() + .from(projects) + .where(eq(projects.id, "project-1")) + .get()?.currentRevision, + 1 + ); + } finally { + fixture.context.close(); + } + }); + + it("can apply the generated inverse as a new auditable revision", () => { + const fixture = createTestFixture(); + try { + const store = new CircuitProjectCommandRepository(fixture.context.db); + const forward = store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitUpdateProjectCommand("circuit-1", { + displayName: "Licht Neu", + protectionRatedCurrent: 16, + }), + }); + + const undone = store.executeUpdate({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + description: "Stromkreisänderung rückgängig machen", + command: forward.inverse, + }); + + const circuit = getCircuit(fixture.context); + assert.equal(circuit.displayName, "Licht Bestand"); + assert.equal(circuit.protectionRatedCurrent, 10); + assert.equal(undone.revision.revisionNumber, 2); + assert.deepEqual( + fixture.context.db + .select({ source: projectRevisions.source }) + .from(projectRevisions) + .all() + .map((revision) => revision.source), + ["user", "undo"] + ); + } finally { + fixture.context.close(); + } + }); + + it("rolls back the circuit update when the expected revision is stale", () => { + const fixture = createTestFixture(); + try { + const store = new CircuitProjectCommandRepository(fixture.context.db); + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitUpdateProjectCommand("circuit-1", { + displayName: "Erste Änderung", + }), + }); + + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitUpdateProjectCommand("circuit-1", { + displayName: "Veraltete Änderung", + }), + }), + (error) => + error instanceof ProjectRevisionConflictError && + error.actualRevision === 1 + ); + + assert.equal(getCircuit(fixture.context).displayName, "Erste Änderung"); + assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 1); + assert.equal(fixture.context.db.select().from(projectChangeSets).all().length, 1); + } finally { + fixture.context.close(); + } + }); + + it("rolls back the circuit update when late history persistence fails", () => { + const fixture = createTestFixture(); + try { + fixture.context.sqlite.exec(` + CREATE TRIGGER fail_circuit_command_change_set + BEFORE INSERT ON project_change_sets + BEGIN + SELECT RAISE(ABORT, 'forced command history failure'); + END; + `); + const store = new CircuitProjectCommandRepository(fixture.context.db); + + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitUpdateProjectCommand("circuit-1", { + displayName: "Darf nicht bleiben", + }), + }), + /forced command history failure/ + ); + + assert.equal(getCircuit(fixture.context).displayName, "Licht Bestand"); + assert.equal( + fixture.context.db + .select() + .from(projects) + .where(eq(projects.id, "project-1")) + .get()?.currentRevision, + 0 + ); + assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 0); + assert.equal(fixture.context.db.select().from(projectChangeSets).all().length, 0); + } finally { + fixture.context.close(); + } + }); + + it("rejects foreign sections and duplicate equipment identifiers before history", () => { + const fixture = createTestFixture(); + try { + const store = new CircuitProjectCommandRepository(fixture.context.db); + + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitUpdateProjectCommand("circuit-1", { + sectionId: fixture.foreignSectionId, + }), + }), + /Section does not belong/ + ); + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitUpdateProjectCommand("circuit-1", { + equipmentIdentifier: "-1F2", + }), + }), + /Duplicate equipmentIdentifier/ + ); + + const circuit = getCircuit(fixture.context); + assert.equal(circuit.sectionId, fixture.sectionId); + assert.equal(circuit.equipmentIdentifier, "-1F1"); + assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 0); + } finally { + fixture.context.close(); + } + }); +}); diff --git a/tests/project-command.model.test.ts b/tests/project-command.model.test.ts index f9d7202..2929fc2 100644 --- a/tests/project-command.model.test.ts +++ b/tests/project-command.model.test.ts @@ -5,6 +5,10 @@ import { serializeProjectCommand, type SerializedProjectCommand, } from "../src/domain/models/project-command.model.js"; +import { + assertCircuitUpdateProjectCommand, + createCircuitUpdateProjectCommand, +} from "../src/domain/models/circuit-project-command.model.js"; describe("serialized project commands", () => { it("round-trips a versioned command envelope", () => { @@ -72,3 +76,49 @@ describe("serialized project commands", () => { ); }); }); + +describe("circuit update project commands", () => { + it("creates a typed command with explicit null clearing semantics", () => { + const command = createCircuitUpdateProjectCommand("circuit-1", { + displayName: null, + cableLength: 12.5, + isReserve: false, + }); + + assert.doesNotThrow(() => assertCircuitUpdateProjectCommand(command)); + assert.deepEqual(command.payload.changes, [ + { field: "displayName", value: null }, + { field: "cableLength", value: 12.5 }, + { field: "isReserve", value: false }, + ]); + }); + + it("rejects empty, duplicate and invalid field changes", () => { + assert.throws( + () => createCircuitUpdateProjectCommand("circuit-1", {}), + /at least one change/ + ); + assert.throws( + () => + assertCircuitUpdateProjectCommand({ + schemaVersion: 1, + type: "circuit.update", + payload: { + circuitId: "circuit-1", + changes: [ + { field: "displayName", value: "A" }, + { field: "displayName", value: "B" }, + ], + }, + }), + /duplicate field/ + ); + assert.throws( + () => + createCircuitUpdateProjectCommand("circuit-1", { + voltage: 0, + }), + /outside its allowed range/ + ); + }); +});