From 0f306d9a90f534010f175a53ff10174997caa0d0 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 26 Jul 2026 11:21:01 +0200 Subject: [PATCH] Reuse command transaction boundary --- AGENTS.md | 3 +- docs/current-architecture.md | 6 +- docs/spec/07-implementation-phases-todo.md | 7 +- ...ice-row-move-project-command.repository.ts | 32 +- ...ion-renumber-project-command.repository.ts | 329 +++++++++--------- ...tion-reorder-project-command.repository.ts | 89 +++-- ...ice-row-sync-project-command.repository.ts | 197 +++++------ 7 files changed, 314 insertions(+), 349 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 53d6cfb..4c44066 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,8 @@ 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. -- Structural command stores share +- Compatible multi-write command stores for 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 3d0fe3d..52f1480 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -84,8 +84,10 @@ 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 Identitäts-/Struktur-Stores für Circuit, CircuitDeviceRow, -DistributionBoard, ProjectDevice sowie Geschoss/Raum verwenden dabei +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 `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 9e6ba12..7ce43ac 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -346,9 +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 -- structural Circuit, CircuitDeviceRow, DistributionBoard, ProjectDevice and - project-location stores share one tested transaction wrapper for domain - mutation, revision append and history transition +- 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 - 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-move-project-command.repository.ts b/src/db/repositories/circuit-device-row-move-project-command.repository.ts index b6de191..ae470fc 100644 --- a/src/db/repositories/circuit-device-row-move-project-command.repository.ts +++ b/src/db/repositories/circuit-device-row-move-project-command.repository.ts @@ -19,8 +19,7 @@ import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuitSections } from "../schema/circuit-sections.js"; import { circuits } from "../schema/circuits.js"; -import { applyProjectHistoryTransition } from "./project-history.persistence.js"; -import { appendProjectRevision } from "./project-revision.persistence.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; interface PersistedMoveRow { id: string; @@ -36,29 +35,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository execute(input: ExecuteCircuitDeviceRowMoveCommandInput) { this.assertSupportedCommand(input.command); - return this.database.transaction((tx) => { - const inverse = this.applyCommand( - tx, - input.projectId, - input.command - ); - const revision = appendProjectRevision(tx, { - projectId: input.projectId, - expectedRevision: input.expectedRevision, - source: input.source, - description: input.description, - actorId: input.actorId, - forward: input.command, - inverse, - }); - applyProjectHistoryTransition(tx, { - projectId: input.projectId, - source: input.source, - recordedChangeSetId: revision.changeSetId, - targetChangeSetId: input.historyTargetChangeSetId, - }); - return { revision, inverse }; - }); + return executeProjectCommandTransaction( + this.database, + input, + (tx) => + this.applyCommand(tx, input.projectId, input.command) + ); } private assertSupportedCommand( diff --git a/src/db/repositories/circuit-section-renumber-project-command.repository.ts b/src/db/repositories/circuit-section-renumber-project-command.repository.ts index 9430a89..2a771b1 100644 --- a/src/db/repositories/circuit-section-renumber-project-command.repository.ts +++ b/src/db/repositories/circuit-section-renumber-project-command.repository.ts @@ -12,8 +12,7 @@ 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 { applyProjectHistoryTransition } from "./project-history.persistence.js"; -import { appendProjectRevision } from "./project-revision.persistence.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; export class CircuitSectionRenumberProjectCommandRepository implements CircuitSectionRenumberProjectCommandStore @@ -23,190 +22,184 @@ export class CircuitSectionRenumberProjectCommandRepository execute(input: ExecuteCircuitSectionRenumberCommandInput) { assertCircuitSectionRenumberProjectCommand(input.command); - return this.database.transaction((tx) => { - const section = tx - .select({ - id: circuitSections.id, - circuitListId: circuitSections.circuitListId, - projectId: circuitLists.projectId, - }) - .from(circuitSections) - .innerJoin( - circuitLists, - eq(circuitLists.id, circuitSections.circuitListId) - ) - .where(eq(circuitSections.id, input.command.payload.sectionId)) - .get(); - if (!section || section.projectId !== input.projectId) { - throw new Error( - "Circuit section does not belong to project." - ); - } + return executeProjectCommandTransaction( + this.database, + input, + (tx) => this.applyCommand(tx, input) + ); + } - const sectionCircuits = tx - .select({ - id: circuits.id, - circuitListId: circuits.circuitListId, - equipmentIdentifier: circuits.equipmentIdentifier, - }) - .from(circuits) - .where(eq(circuits.sectionId, section.id)) - .all(); - const assignments = input.command.payload.assignments; + private applyCommand( + tx: AppDatabase, + input: ExecuteCircuitSectionRenumberCommandInput + ) { + const section = tx + .select({ + id: circuitSections.id, + circuitListId: circuitSections.circuitListId, + projectId: circuitLists.projectId, + }) + .from(circuitSections) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuitSections.circuitListId) + ) + .where(eq(circuitSections.id, input.command.payload.sectionId)) + .get(); + if (!section || section.projectId !== input.projectId) { + throw new Error( + "Circuit section does not belong to project." + ); + } + + const sectionCircuits = tx + .select({ + id: circuits.id, + circuitListId: circuits.circuitListId, + equipmentIdentifier: circuits.equipmentIdentifier, + }) + .from(circuits) + .where(eq(circuits.sectionId, section.id)) + .all(); + const assignments = input.command.payload.assignments; + if ( + sectionCircuits.length !== assignments.length || + sectionCircuits.some( + (circuit) => + circuit.circuitListId !== section.circuitListId + ) + ) { + throw new Error( + "Circuit renumber must include every circuit in the section." + ); + } + const circuitsById = new Map( + sectionCircuits.map((circuit) => [circuit.id, circuit]) + ); + for (const assignment of assignments) { + const circuit = circuitsById.get(assignment.circuitId); if ( - sectionCircuits.length !== assignments.length || - sectionCircuits.some( - (circuit) => - circuit.circuitListId !== section.circuitListId - ) + !circuit || + circuit.equipmentIdentifier !== + assignment.expectedEquipmentIdentifier ) { throw new Error( - "Circuit renumber must include every circuit in the section." + "Circuit changed before section renumber execution." ); } - const circuitsById = new Map( - sectionCircuits.map((circuit) => [circuit.id, circuit]) - ); - for (const assignment of assignments) { - const circuit = circuitsById.get(assignment.circuitId); - if ( - !circuit || - circuit.equipmentIdentifier !== - assignment.expectedEquipmentIdentifier - ) { - throw new Error( - "Circuit changed before section renumber execution." - ); - } - } + } - const listCircuits = tx - .select({ - id: circuits.id, - equipmentIdentifier: circuits.equipmentIdentifier, - }) - .from(circuits) - .where(eq(circuits.circuitListId, section.circuitListId)) - .all(); - const sectionCircuitIds = new Set( - assignments.map((assignment) => assignment.circuitId) + const listCircuits = tx + .select({ + id: circuits.id, + equipmentIdentifier: circuits.equipmentIdentifier, + }) + .from(circuits) + .where(eq(circuits.circuitListId, section.circuitListId)) + .all(); + const sectionCircuitIds = new Set( + assignments.map((assignment) => assignment.circuitId) + ); + const targetIdentifiers = new Set( + assignments.map( + (assignment) => assignment.targetEquipmentIdentifier + ) + ); + const conflictingCircuit = listCircuits.find( + (circuit) => + !sectionCircuitIds.has(circuit.id) && + targetIdentifiers.has(circuit.equipmentIdentifier) + ); + if (conflictingCircuit) { + throw new Error( + "Target equipment identifier already exists in circuit list." ); - const targetIdentifiers = new Set( - assignments.map( - (assignment) => assignment.targetEquipmentIdentifier + } + + const inverse = createCircuitSectionRenumberProjectCommand( + section.id, + assignments.map((assignment) => ({ + circuitId: assignment.circuitId, + expectedEquipmentIdentifier: + assignment.targetEquipmentIdentifier, + targetEquipmentIdentifier: + assignment.expectedEquipmentIdentifier, + })) + ); + const temporaryIdentifiers = this.createTemporaryIdentifiers( + section.id, + assignments, + listCircuits.map((circuit) => circuit.equipmentIdentifier) + ); + + for (const assignment of assignments) { + if ( + assignment.expectedEquipmentIdentifier === + assignment.targetEquipmentIdentifier + ) { + continue; + } + const temporaryIdentifier = temporaryIdentifiers.get( + assignment.circuitId + )!; + const updated = tx + .update(circuits) + .set({ equipmentIdentifier: temporaryIdentifier }) + .where( + and( + eq(circuits.id, assignment.circuitId), + eq(circuits.sectionId, section.id), + eq(circuits.circuitListId, section.circuitListId), + eq( + circuits.equipmentIdentifier, + assignment.expectedEquipmentIdentifier + ) + ) ) - ); - const conflictingCircuit = listCircuits.find( - (circuit) => - !sectionCircuitIds.has(circuit.id) && - targetIdentifiers.has(circuit.equipmentIdentifier) - ); - if (conflictingCircuit) { + .run(); + if (updated.changes !== 1) { throw new Error( - "Target equipment identifier already exists in circuit list." + "Circuit changed during section renumber execution." ); } + } - const inverse = createCircuitSectionRenumberProjectCommand( - section.id, - assignments.map((assignment) => ({ - circuitId: assignment.circuitId, - expectedEquipmentIdentifier: + for (const assignment of assignments) { + if ( + assignment.expectedEquipmentIdentifier === + assignment.targetEquipmentIdentifier + ) { + continue; + } + const temporaryIdentifier = temporaryIdentifiers.get( + assignment.circuitId + )!; + const updated = tx + .update(circuits) + .set({ + equipmentIdentifier: assignment.targetEquipmentIdentifier, - targetEquipmentIdentifier: - assignment.expectedEquipmentIdentifier, - })) - ); - const temporaryIdentifiers = this.createTemporaryIdentifiers( - section.id, - assignments, - listCircuits.map((circuit) => circuit.equipmentIdentifier) - ); - - for (const assignment of assignments) { - if ( - assignment.expectedEquipmentIdentifier === - assignment.targetEquipmentIdentifier - ) { - continue; - } - const temporaryIdentifier = temporaryIdentifiers.get( - assignment.circuitId - )!; - const updated = tx - .update(circuits) - .set({ equipmentIdentifier: temporaryIdentifier }) - .where( - and( - eq(circuits.id, assignment.circuitId), - eq(circuits.sectionId, section.id), - eq(circuits.circuitListId, section.circuitListId), - eq( - circuits.equipmentIdentifier, - assignment.expectedEquipmentIdentifier - ) + }) + .where( + and( + eq(circuits.id, assignment.circuitId), + eq(circuits.sectionId, section.id), + eq(circuits.circuitListId, section.circuitListId), + eq( + circuits.equipmentIdentifier, + temporaryIdentifier ) ) - .run(); - if (updated.changes !== 1) { - throw new Error( - "Circuit changed during section renumber execution." - ); - } + ) + .run(); + if (updated.changes !== 1) { + throw new Error( + "Circuit changed during final section renumber execution." + ); } + } - for (const assignment of assignments) { - if ( - assignment.expectedEquipmentIdentifier === - assignment.targetEquipmentIdentifier - ) { - continue; - } - const temporaryIdentifier = temporaryIdentifiers.get( - assignment.circuitId - )!; - const updated = tx - .update(circuits) - .set({ - equipmentIdentifier: - assignment.targetEquipmentIdentifier, - }) - .where( - and( - eq(circuits.id, assignment.circuitId), - eq(circuits.sectionId, section.id), - eq(circuits.circuitListId, section.circuitListId), - eq( - circuits.equipmentIdentifier, - temporaryIdentifier - ) - ) - ) - .run(); - if (updated.changes !== 1) { - throw new Error( - "Circuit changed during final section renumber 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 createTemporaryIdentifiers( diff --git a/src/db/repositories/circuit-section-reorder-project-command.repository.ts b/src/db/repositories/circuit-section-reorder-project-command.repository.ts index c6fe281..6e0b0b6 100644 --- a/src/db/repositories/circuit-section-reorder-project-command.repository.ts +++ b/src/db/repositories/circuit-section-reorder-project-command.repository.ts @@ -21,8 +21,7 @@ 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 { applyProjectHistoryTransition } from "./project-history.persistence.js"; -import { appendProjectRevision } from "./project-revision.persistence.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; type ReorderHistoryCommand = | CircuitSectionReorderProjectCommand @@ -36,54 +35,48 @@ export class CircuitSectionReorderProjectCommandRepository execute(input: ExecuteCircuitSectionReorderCommandInput) { this.assertSupportedCommand(input.command); - return this.database.transaction((tx) => { - const commandSections = this.getCommandSections(input.command); - const inverseSections = commandSections.map((section) => { - this.assertCompleteCurrentSection( - tx, - input.projectId, - section - ); - return { - sectionId: section.sectionId, - assignments: section.assignments.map((assignment) => ({ - circuitId: assignment.circuitId, - expectedSortOrder: assignment.targetSortOrder, - targetSortOrder: assignment.expectedSortOrder, - })), - }; - }); + return executeProjectCommandTransaction( + this.database, + input, + (tx) => this.applyCommand(tx, input) + ); + } - for (const section of commandSections) { - this.applyAssignments(tx, section); - } - - const inverse = - input.command.type === circuitSectionReorderCommandType - ? createCircuitSectionReorderProjectCommand( - inverseSections[0].sectionId, - inverseSections[0].assignments - ) - : createCircuitSectionsReorderProjectCommand( - inverseSections - ); - const revision = appendProjectRevision(tx, { - projectId: input.projectId, - expectedRevision: input.expectedRevision, - source: input.source, - description: input.description, - actorId: input.actorId, - forward: input.command, - inverse, - }); - applyProjectHistoryTransition(tx, { - projectId: input.projectId, - source: input.source, - recordedChangeSetId: revision.changeSetId, - targetChangeSetId: input.historyTargetChangeSetId, - }); - return { revision, inverse }; + private applyCommand( + tx: AppDatabase, + input: ExecuteCircuitSectionReorderCommandInput + ) { + const commandSections = this.getCommandSections(input.command); + const inverseSections = commandSections.map((section) => { + this.assertCompleteCurrentSection( + tx, + input.projectId, + section + ); + return { + sectionId: section.sectionId, + assignments: section.assignments.map((assignment) => ({ + circuitId: assignment.circuitId, + expectedSortOrder: assignment.targetSortOrder, + targetSortOrder: assignment.expectedSortOrder, + })), + }; }); + + for (const section of commandSections) { + this.applyAssignments(tx, section); + } + + const inverse = + input.command.type === circuitSectionReorderCommandType + ? createCircuitSectionReorderProjectCommand( + inverseSections[0].sectionId, + inverseSections[0].assignments + ) + : createCircuitSectionsReorderProjectCommand( + inverseSections + ); + return inverse; } private assertSupportedCommand( diff --git a/src/db/repositories/project-device-row-sync-project-command.repository.ts b/src/db/repositories/project-device-row-sync-project-command.repository.ts index e38a45f..d4265a0 100644 --- a/src/db/repositories/project-device-row-sync-project-command.repository.ts +++ b/src/db/repositories/project-device-row-sync-project-command.repository.ts @@ -15,8 +15,7 @@ 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 { applyProjectHistoryTransition } from "./project-history.persistence.js"; -import { appendProjectRevision } from "./project-revision.persistence.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect; @@ -28,116 +27,110 @@ export class ProjectDeviceRowSyncProjectCommandRepository execute(input: ExecuteProjectDeviceRowSyncCommandInput) { assertProjectDeviceRowSyncProjectCommand(input.command); - return this.database.transaction((tx) => { - const projectDevice = tx - .select({ id: projectDevices.id }) - .from(projectDevices) - .where( - and( - eq( - projectDevices.id, - input.command.payload.projectDeviceId - ), - eq(projectDevices.projectId, input.projectId) - ) - ) - .get(); - if (!projectDevice) { - throw new Error( - "Project device does not belong to project." - ); - } + return executeProjectCommandTransaction( + this.database, + input, + (tx) => this.applyCommand(tx, input) + ); + } - const rowIds = input.command.payload.rows.map( - (row) => row.rowId + private applyCommand( + tx: AppDatabase, + input: ExecuteProjectDeviceRowSyncCommandInput + ) { + const projectDevice = tx + .select({ id: projectDevices.id }) + .from(projectDevices) + .where( + and( + eq( + projectDevices.id, + input.command.payload.projectDeviceId + ), + eq(projectDevices.projectId, input.projectId) + ) + ) + .get(); + if (!projectDevice) { + throw new Error( + "Project device does not belong to project." ); - const persistedRows = tx - .select({ - row: circuitDeviceRows, - projectId: circuitLists.projectId, - }) - .from(circuitDeviceRows) - .innerJoin( - circuits, - eq(circuits.id, circuitDeviceRows.circuitId) - ) - .innerJoin( - circuitLists, - eq(circuitLists.id, circuits.circuitListId) - ) - .where(inArray(circuitDeviceRows.id, rowIds)) - .all(); + } + + const rowIds = input.command.payload.rows.map( + (row) => row.rowId + ); + const persistedRows = tx + .select({ + row: circuitDeviceRows, + projectId: circuitLists.projectId, + }) + .from(circuitDeviceRows) + .innerJoin( + circuits, + eq(circuits.id, circuitDeviceRows.circuitId) + ) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuits.circuitListId) + ) + .where(inArray(circuitDeviceRows.id, rowIds)) + .all(); + if ( + persistedRows.length !== rowIds.length || + persistedRows.some( + (persisted) => persisted.projectId !== input.projectId + ) + ) { + throw new Error( + "One or more sync rows do not belong to project." + ); + } + + const persistedById = new Map( + persistedRows.map((persisted) => [ + persisted.row.id, + persisted.row, + ]) + ); + for (const assignment of input.command.payload.rows) { + const persisted = persistedById.get(assignment.rowId); if ( - persistedRows.length !== rowIds.length || - persistedRows.some( - (persisted) => persisted.projectId !== input.projectId - ) + !persisted || + !snapshotMatchesRow(assignment.expected, persisted) ) { throw new Error( - "One or more sync rows do not belong to project." + "Project-device row changed before sync execution." ); } + } - const persistedById = new Map( - persistedRows.map((persisted) => [ - persisted.row.id, - persisted.row, - ]) - ); - for (const assignment of input.command.payload.rows) { - const persisted = persistedById.get(assignment.rowId); - if ( - !persisted || - !snapshotMatchesRow(assignment.expected, persisted) - ) { - throw new Error( - "Project-device row changed before sync execution." - ); - } + const inverse = createProjectDeviceRowSyncProjectCommand( + projectDevice.id, + invertProjectDeviceRowSyncOperation( + input.command.payload.operation + ), + input.command.payload.rows.map((row) => ({ + rowId: row.rowId, + expected: row.target, + target: row.expected, + })) + ); + + for (const assignment of input.command.payload.rows) { + const updated = tx + .update(circuitDeviceRows) + .set(assignment.target) + .where(eq(circuitDeviceRows.id, assignment.rowId)) + .run(); + if (updated.changes !== 1) { + throw new Error( + "Project-device row changed during sync execution." + ); } + } - const inverse = createProjectDeviceRowSyncProjectCommand( - projectDevice.id, - invertProjectDeviceRowSyncOperation( - input.command.payload.operation - ), - input.command.payload.rows.map((row) => ({ - rowId: row.rowId, - expected: row.target, - target: row.expected, - })) - ); - - for (const assignment of input.command.payload.rows) { - const updated = tx - .update(circuitDeviceRows) - .set(assignment.target) - .where(eq(circuitDeviceRows.id, assignment.rowId)) - .run(); - if (updated.changes !== 1) { - throw new Error( - "Project-device row changed during sync 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; } }