Centralize structural command transactions

This commit is contained in:
2026-07-26 11:14:24 +02:00
parent d23e7d990c
commit 266bdb4749
9 changed files with 112 additions and 128 deletions
+3
View File
@@ -21,6 +21,9 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
`src/frontend/components/circuit-grid-*.ts` modules. `src/frontend/components/circuit-grid-*.ts` modules.
- Critical multi-write commands use injected transaction repositories with real - Critical multi-write commands use injected transaction repositories with real
SQLite commit/rollback tests. SQLite commit/rollback tests.
- Structural command stores 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 - General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
repositories expose only active reads. Runtime writes belong in typed command repositories expose only active reads. Runtime writes belong in typed command
repositories; direct integration fixtures belong under `tests/support`. repositories; direct integration fixtures belong under `tests/support`.
+7
View File
@@ -84,6 +84,13 @@ absteigend paginierte Revisions-Timeline ist ohne Befehls-Payloads über
`GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler `GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler
Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über
öffentliche Command-, Undo- und Redo-Endpunkte aus. öffentliche Command-, Undo- und Redo-Endpunkte aus.
Die Identitäts-/Struktur-Stores für Circuit, CircuitDeviceRow,
DistributionBoard, ProjectDevice 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
aus; die fachlichen Validierungs- und Inversenregeln bleiben im jeweiligen
Store.
`circuit-device-row.insert` und `circuit-device-row.delete` sind atomare `circuit-device-row.insert` und `circuit-device-row.delete` sind atomare
Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen
Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte wiederherstellt. Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte wiederherstellt.
@@ -346,6 +346,9 @@ Implemented foundation:
- obsolete direct Circuit, CircuitDeviceRow, CircuitList, DistributionBoard and - obsolete direct Circuit, CircuitDeviceRow, CircuitList, DistributionBoard and
Project repository writes are removed; integration fixtures live under Project repository writes are removed; integration fixtures live under
`tests/support` instead of production repositories `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
- the legacy consumer UI and application read/write endpoints are removed after verified data cutover - 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 - retained legacy rows are accessible only through explicit database upgrade tooling
- project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields - project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields
@@ -21,8 +21,7 @@ import {
assertCircuitDeviceRowReferencesInProject, assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowSnapshot, toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js"; } from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class CircuitDeviceRowStructureProjectCommandRepository export class CircuitDeviceRowStructureProjectCommandRepository
implements CircuitDeviceRowStructureProjectCommandStore implements CircuitDeviceRowStructureProjectCommandStore
@@ -30,30 +29,17 @@ export class CircuitDeviceRowStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) { execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const inverse = this.applyCommand( this.database,
input,
(tx) =>
this.applyCommand(
tx, tx,
input.projectId, input.projectId,
input.source, input.source,
input.command 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 };
});
} }
private applyCommand( private applyCommand(
@@ -22,8 +22,7 @@ import {
assertCircuitDeviceRowReferencesInProject, assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowSnapshot, toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js"; } from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class CircuitStructureProjectCommandRepository export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore implements CircuitStructureProjectCommandStore
@@ -31,30 +30,17 @@ export class CircuitStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitStructureCommandInput) { execute(input: ExecuteCircuitStructureCommandInput) {
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const inverse = this.applyCommand( this.database,
input,
(tx) =>
this.applyCommand(
tx, tx,
input.projectId, input.projectId,
input.source, input.source,
input.command 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 };
});
} }
private applyCommand( private applyCommand(
@@ -19,8 +19,7 @@ import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js"; import { distributionBoards } from "../schema/distribution-boards.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class DistributionBoardStructureProjectCommandRepository export class DistributionBoardStructureProjectCommandRepository
implements DistributionBoardStructureProjectCommandStore implements DistributionBoardStructureProjectCommandStore
@@ -28,29 +27,12 @@ export class DistributionBoardStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteDistributionBoardStructureCommandInput) { execute(input: ExecuteDistributionBoardStructureCommandInput) {
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const inverse = this.applyCommand( this.database,
tx, input,
input.projectId, (tx) =>
input.command 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 };
});
} }
private applyCommand( private applyCommand(
@@ -0,0 +1,49 @@
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export interface ProjectCommandTransactionInput<
Command extends SerializedProjectCommand<unknown>,
> {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: Command;
}
export function executeProjectCommandTransaction<
Command extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>,
>(
database: AppDatabase,
input: ProjectCommandTransactionInput<Command>,
applyCommand: (transaction: AppDatabase) => Inverse
): { revision: AppendedProjectRevision; inverse: Inverse } {
return database.transaction((tx) => {
const 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,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
@@ -27,8 +27,7 @@ import { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js"; import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
const circuitDeviceRowSnapshotFields = [ const circuitDeviceRowSnapshotFields = [
"id", "id",
@@ -60,30 +59,17 @@ export class ProjectDeviceStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectDeviceStructureCommandInput) { execute(input: ExecuteProjectDeviceStructureCommandInput) {
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const inverse = this.applyCommand( this.database,
input,
(tx) =>
this.applyCommand(
tx, tx,
input.projectId, input.projectId,
input.source, input.source,
input.command 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 };
});
} }
private applyCommand( private applyCommand(
@@ -26,8 +26,7 @@ import { consumers } from "../schema/consumers.js";
import { floors } from "../schema/floors.js"; import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class ProjectLocationStructureProjectCommandRepository export class ProjectLocationStructureProjectCommandRepository
implements ProjectLocationStructureProjectCommandStore implements ProjectLocationStructureProjectCommandStore
@@ -35,29 +34,12 @@ export class ProjectLocationStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectLocationStructureCommandInput) { execute(input: ExecuteProjectLocationStructureCommandInput) {
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const inverse = this.applyCommand( this.database,
tx, input,
input.projectId, (tx) =>
input.command 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 };
});
} }
private applyCommand( private applyCommand(