From e4d22caf099e4a29780d19c657dae85c2cc375c3 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 26 Jul 2026 11:24:19 +0200 Subject: [PATCH] Centralize update command transactions --- AGENTS.md | 5 +- docs/current-architecture.md | 9 +- docs/spec/07-implementation-phases-todo.md | 8 +- .../circuit-project-command.repository.ts | 134 ++++++++---------- ...oject-device-project-command.repository.ts | 127 ++++++++--------- ...ect-settings-project-command.repository.ts | 87 ++++++------ 6 files changed, 175 insertions(+), 195 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4c44066..568ac7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,8 +21,9 @@ It must support circuits, device rows, project devices, drag-and-drop restructur `src/frontend/components/circuit-grid-*.ts` modules. - Critical multi-write commands use injected transaction repositories with real SQLite commit/rollback tests. -- Compatible multi-write command stores for structure, device-row moves, - section reorder/renumber and project-device row synchronization share +- Compatible command stores for Circuit and ProjectDevice field updates, + project settings, structure, device-row moves, section reorder/renumber and + project-device row synchronization share `src/db/repositories/project-command-transaction.persistence.ts` for the atomic domain-write, revision and history transition boundary. - General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 52f1480..c593e61 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -84,10 +84,11 @@ absteigend paginierte Revisions-Timeline ist ohne Befehls-Payloads über `GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über öffentliche Command-, Undo- und Redo-Endpunkte aus. -Die kompatiblen Multi-Write-Stores für Circuit-/CircuitDeviceRow-Struktur, -Gerätezeilen-Moves, Abschnittssortierung/-umnummerierung, -DistributionBoard-Struktur, ProjectDevice-Struktur und -Zeilensynchronisierung -sowie Geschoss/Raum verwenden dabei +Die kompatiblen Command-Stores für Circuit- und ProjectDevice-Feldänderungen, +Projekteinstellungen, Circuit-/CircuitDeviceRow-Struktur, Gerätezeilen-Moves, +Abschnittssortierung/-umnummerierung, DistributionBoard-Struktur, +ProjectDevice-Struktur und -Zeilensynchronisierung sowie Geschoss/Raum verwenden +dabei `project-command-transaction.persistence.ts` als gemeinsame äußere Transaktionsgrenze. Sie führt Fachänderung, Revisions-Append und History-Transition in fester Reihenfolge innerhalb derselben SQLite-Transaktion diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 7ce43ac..2971361 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -346,10 +346,10 @@ Implemented foundation: - obsolete direct Circuit, CircuitDeviceRow, CircuitList, DistributionBoard and Project repository writes are removed; integration fixtures live under `tests/support` instead of production repositories -- compatible structure, device-row move, section reorder/renumber, - ProjectDevice row-sync and project-location stores share one tested - transaction wrapper for domain mutation, revision append and history - transition +- compatible Circuit/ProjectDevice update, project-settings, structure, + device-row move, section reorder/renumber, ProjectDevice row-sync and + project-location stores share one tested transaction wrapper for domain + mutation, revision append and history transition - the legacy consumer UI and application read/write endpoints are removed after verified data cutover - retained legacy rows are accessible only through explicit database upgrade tooling - project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields diff --git a/src/db/repositories/circuit-project-command.repository.ts b/src/db/repositories/circuit-project-command.repository.ts index a5382ec..45702fc 100644 --- a/src/db/repositories/circuit-project-command.repository.ts +++ b/src/db/repositories/circuit-project-command.repository.ts @@ -18,8 +18,7 @@ import { toCircuitPatchValues, type CircuitPatchPersistenceInput, } from "./circuit.persistence.js"; -import { applyProjectHistoryTransition } from "./project-history.persistence.js"; -import { appendProjectRevision } from "./project-revision.persistence.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; type CircuitRow = typeof circuits.$inferSelect; @@ -31,81 +30,74 @@ export class CircuitProjectCommandRepository 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."); - } + return executeProjectCommandTransaction( + this.database, + input, + (tx) => this.applyCommand(tx, input) + ); + } - const owningList = tx - .select({ id: circuitLists.id }) - .from(circuitLists) - .where( - and( - eq(circuitLists.id, current.circuitListId), - eq(circuitLists.projectId, input.projectId) - ) + private applyCommand( + tx: AppDatabase, + input: ExecuteCircuitUpdateCommandInput + ) { + 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."); - } + ) + .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 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 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 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, - }); - applyProjectHistoryTransition(tx, { - projectId: input.projectId, - source: input.source, - recordedChangeSetId: revision.changeSetId, - targetChangeSetId: input.historyTargetChangeSetId, - }); - - return { revision, inverse }; - }); + return inverse; } private assertSectionInCircuitList( diff --git a/src/db/repositories/project-device-project-command.repository.ts b/src/db/repositories/project-device-project-command.repository.ts index e249ee3..dca7afc 100644 --- a/src/db/repositories/project-device-project-command.repository.ts +++ b/src/db/repositories/project-device-project-command.repository.ts @@ -12,8 +12,7 @@ import type { } from "../../domain/ports/project-device-project-command.store.js"; import type { AppDatabase } from "../database-context.js"; import { projectDevices } from "../schema/project-devices.js"; -import { applyProjectHistoryTransition } from "./project-history.persistence.js"; -import { appendProjectRevision } from "./project-revision.persistence.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; type ProjectDeviceRow = typeof projectDevices.$inferSelect; @@ -25,76 +24,70 @@ export class ProjectDeviceProjectCommandRepository executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) { assertProjectDeviceUpdateProjectCommand(input.command); - return this.database.transaction((tx) => { - const current = tx - .select() - .from(projectDevices) - .where( - and( - eq( - projectDevices.id, - input.command.payload.projectDeviceId - ), - eq(projectDevices.projectId, input.projectId) - ) - ) - .get(); - if (!current) { - throw new Error( - "Project device does not belong to project." - ); - } + return executeProjectCommandTransaction( + this.database, + input, + (tx) => this.applyCommand(tx, input) + ); + } - const patch = Object.fromEntries( - input.command.payload.changes.map((change) => [ - change.field, - change.value, - ]) - ) as ProjectDeviceUpdatePatch; - const inversePatch = Object.fromEntries( - input.command.payload.changes.map((change) => [ - change.field, - getProjectDeviceFieldValue(current, change.field), - ]) - ) as ProjectDeviceUpdatePatch; - const inverse = createProjectDeviceUpdateProjectCommand( - current.id, - inversePatch + private applyCommand( + tx: AppDatabase, + input: ExecuteProjectDeviceUpdateCommandInput + ) { + const current = tx + .select() + .from(projectDevices) + .where( + and( + eq( + projectDevices.id, + input.command.payload.projectDeviceId + ), + eq(projectDevices.projectId, input.projectId) + ) + ) + .get(); + if (!current) { + throw new Error( + "Project device does not belong to project." ); + } - const updated = tx - .update(projectDevices) - .set(patch) - .where( - and( - eq(projectDevices.id, current.id), - eq(projectDevices.projectId, input.projectId) - ) + const patch = Object.fromEntries( + input.command.payload.changes.map((change) => [ + change.field, + change.value, + ]) + ) as ProjectDeviceUpdatePatch; + const inversePatch = Object.fromEntries( + input.command.payload.changes.map((change) => [ + change.field, + getProjectDeviceFieldValue(current, change.field), + ]) + ) as ProjectDeviceUpdatePatch; + const inverse = createProjectDeviceUpdateProjectCommand( + current.id, + inversePatch + ); + + const updated = tx + .update(projectDevices) + .set(patch) + .where( + and( + eq(projectDevices.id, current.id), + eq(projectDevices.projectId, input.projectId) ) - .run(); - if (updated.changes !== 1) { - throw new Error( - "Project device changed before command execution." - ); - } + ) + .run(); + if (updated.changes !== 1) { + throw new Error( + "Project device changed before command execution." + ); + } - const revision = appendProjectRevision(tx, { - projectId: input.projectId, - expectedRevision: input.expectedRevision, - source: input.source, - description: input.description, - actorId: input.actorId, - forward: input.command, - inverse, - }); - applyProjectHistoryTransition(tx, { - projectId: input.projectId, - source: input.source, - recordedChangeSetId: revision.changeSetId, - targetChangeSetId: input.historyTargetChangeSetId, - }); - return { revision, inverse }; - }); + return inverse; } } diff --git a/src/db/repositories/project-settings-project-command.repository.ts b/src/db/repositories/project-settings-project-command.repository.ts index e60c579..f767b8f 100644 --- a/src/db/repositories/project-settings-project-command.repository.ts +++ b/src/db/repositories/project-settings-project-command.repository.ts @@ -9,8 +9,7 @@ import type { } from "../../domain/ports/project-settings-project-command.store.js"; import type { AppDatabase } from "../database-context.js"; import { projects } from "../schema/projects.js"; -import { applyProjectHistoryTransition } from "./project-history.persistence.js"; -import { appendProjectRevision } from "./project-revision.persistence.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; export class ProjectSettingsProjectCommandRepository implements ProjectSettingsProjectCommandStore @@ -19,53 +18,47 @@ export class ProjectSettingsProjectCommandRepository executeUpdate(input: ExecuteProjectSettingsUpdateCommandInput) { assertProjectSettingsUpdateProjectCommand(input.command); - return this.database.transaction((tx) => { - const current = tx - .select() - .from(projects) - .where(eq(projects.id, input.projectId)) - .get(); - if (!current) { - throw new Error("Project not found."); - } - if ( - current.singlePhaseVoltageV === - input.command.payload.singlePhaseVoltageV && - current.threePhaseVoltageV === - input.command.payload.threePhaseVoltageV - ) { - throw new Error("Project settings did not change."); - } + return executeProjectCommandTransaction( + this.database, + input, + (tx) => this.applyCommand(tx, input) + ); + } - const inverse = createProjectSettingsUpdateProjectCommand({ - singlePhaseVoltageV: current.singlePhaseVoltageV, - threePhaseVoltageV: current.threePhaseVoltageV, - }); - const updated = tx - .update(projects) - .set(input.command.payload) - .where(eq(projects.id, input.projectId)) - .run(); - if (updated.changes !== 1) { - throw new Error("Project changed before settings update."); - } + private applyCommand( + tx: AppDatabase, + input: ExecuteProjectSettingsUpdateCommandInput + ) { + const current = tx + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + if (!current) { + throw new Error("Project not found."); + } + if ( + current.singlePhaseVoltageV === + input.command.payload.singlePhaseVoltageV && + current.threePhaseVoltageV === + input.command.payload.threePhaseVoltageV + ) { + throw new Error("Project settings did not change."); + } - 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 }; + const inverse = createProjectSettingsUpdateProjectCommand({ + singlePhaseVoltageV: current.singlePhaseVoltageV, + threePhaseVoltageV: current.threePhaseVoltageV, }); + const updated = tx + .update(projects) + .set(input.command.payload) + .where(eq(projects.id, input.projectId)) + .run(); + if (updated.changes !== 1) { + throw new Error("Project changed before settings update."); + } + + return inverse; } }