From 214ad728cd23c21a0cd56dd48c8f21ba9c2ec2d6 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 26 Jul 2026 11:28:06 +0200 Subject: [PATCH] Centralize device row update transactions --- AGENTS.md | 7 +- docs/current-architecture.md | 10 +- docs/spec/07-implementation-phases-todo.md | 7 +- ...t-device-row-project-command.repository.ts | 171 +++++++++--------- ...project-command-transaction.persistence.ts | 51 +++++- 5 files changed, 142 insertions(+), 104 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 568ac7a..95a0c26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,11 +21,10 @@ 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 command stores for Circuit and ProjectDevice field updates, - project settings, structure, device-row moves, section reorder/renumber and - project-device row synchronization share +- All supported runtime project-command stores share `src/db/repositories/project-command-transaction.persistence.ts` for the - atomic domain-write, revision and history transition boundary. + atomic domain-write, revision and history transition boundary. Its applied + forward-command variant preserves derived CircuitDeviceRow override metadata. - General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard repositories expose only active reads. Runtime writes belong in typed command repositories; direct integration fixtures belong under `tests/support`. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index c593e61..d3b0cb8 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -84,15 +84,13 @@ 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 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 +Alle unterstützten Runtime-Project-Command-Stores 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 -aus; die fachlichen Validierungs- und Inversenregeln bleiben im jeweiligen +aus. Für CircuitDeviceRow-Feldänderungen übernimmt eine typisierte Variante den +während der Fachänderung um Override-Metadaten ergänzten Forward-Command. Die +fachlichen Validierungs-, Forward- und Inversenregeln bleiben im jeweiligen Store. `circuit-device-row.insert` und `circuit-device-row.delete` sind atomare Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 2971361..6e5576a 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -346,10 +346,9 @@ 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 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 +- all supported runtime project-command stores share one tested transaction + wrapper for domain mutation, revision append and history transition; its + applied-forward variant retains derived CircuitDeviceRow override metadata - 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-device-row-project-command.repository.ts b/src/db/repositories/circuit-device-row-project-command.repository.ts index ebed605..dd79c86 100644 --- a/src/db/repositories/circuit-device-row-project-command.repository.ts +++ b/src/db/repositories/circuit-device-row-project-command.repository.ts @@ -21,8 +21,7 @@ import { toCircuitDeviceRowPatchValues, type CircuitDeviceRowPatchInput, } from "./circuit-device-row.persistence.js"; -import { applyProjectHistoryTransition } from "./project-history.persistence.js"; -import { appendProjectRevision } from "./project-revision.persistence.js"; +import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js"; type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect; @@ -34,99 +33,95 @@ export class CircuitDeviceRowProjectCommandRepository 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."); - } + return executeProjectCommandTransactionWithAppliedForward( + this.database, + input, + (tx) => this.applyCommand(tx, input) + ); + } - 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) - ) + private applyCommand( + tx: AppDatabase, + input: ExecuteCircuitDeviceRowUpdateCommandInput + ) { + 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."); - } + ) + .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 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 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 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 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, - }); - applyProjectHistoryTransition(tx, { - projectId: input.projectId, - source: input.source, - recordedChangeSetId: revision.changeSetId, - targetChangeSetId: input.historyTargetChangeSetId, - }); - - return { revision, inverse }; - }); + return { + forward: appliedForward, + inverse, + }; } private assertLinkedProjectDevice( diff --git a/src/db/repositories/project-command-transaction.persistence.ts b/src/db/repositories/project-command-transaction.persistence.ts index 62d661e..49ea77d 100644 --- a/src/db/repositories/project-command-transaction.persistence.ts +++ b/src/db/repositories/project-command-transaction.persistence.ts @@ -19,6 +19,14 @@ export interface ProjectCommandTransactionInput< command: Command; } +export interface AppliedProjectCommand< + Forward extends SerializedProjectCommand, + Inverse extends SerializedProjectCommand, +> { + forward: Forward; + inverse: Inverse; +} + export function executeProjectCommandTransaction< Command extends SerializedProjectCommand, Inverse extends SerializedProjectCommand, @@ -26,16 +34,55 @@ export function executeProjectCommandTransaction< database: AppDatabase, input: ProjectCommandTransactionInput, applyCommand: (transaction: AppDatabase) => Inverse +): { revision: AppendedProjectRevision; inverse: Inverse } { + return executeAppliedProjectCommandTransaction( + database, + input, + (transaction) => ({ + forward: input.command, + inverse: applyCommand(transaction), + }) + ); +} + +export function executeProjectCommandTransactionWithAppliedForward< + Command extends SerializedProjectCommand, + Forward extends SerializedProjectCommand, + Inverse extends SerializedProjectCommand, +>( + database: AppDatabase, + input: ProjectCommandTransactionInput, + applyCommand: ( + transaction: AppDatabase + ) => AppliedProjectCommand +): { revision: AppendedProjectRevision; inverse: Inverse } { + return executeAppliedProjectCommandTransaction( + database, + input, + applyCommand + ); +} + +function executeAppliedProjectCommandTransaction< + Command extends SerializedProjectCommand, + Forward extends SerializedProjectCommand, + Inverse extends SerializedProjectCommand, +>( + database: AppDatabase, + input: ProjectCommandTransactionInput, + applyCommand: ( + transaction: AppDatabase + ) => AppliedProjectCommand ): { revision: AppendedProjectRevision; inverse: Inverse } { return database.transaction((tx) => { - const inverse = applyCommand(tx); + const { forward, inverse } = applyCommand(tx); const revision = appendProjectRevision(tx, { projectId: input.projectId, expectedRevision: input.expectedRevision, source: input.source, description: input.description, actorId: input.actorId, - forward: input.command, + forward, inverse, }); applyProjectHistoryTransition(tx, {