Centralize update command transactions

This commit is contained in:
2026-07-26 11:24:19 +02:00
parent 0f306d9a90
commit e4d22caf09
6 changed files with 175 additions and 195 deletions
+3 -2
View File
@@ -21,8 +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.
- Compatible multi-write command stores for structure, device-row moves, - Compatible command stores for Circuit and ProjectDevice field updates,
section reorder/renumber and project-device row synchronization share 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 `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.
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard - General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
+5 -4
View File
@@ -84,10 +84,11 @@ 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 kompatiblen Multi-Write-Stores für Circuit-/CircuitDeviceRow-Struktur, Die kompatiblen Command-Stores für Circuit- und ProjectDevice-Feldänderungen,
Gerätezeilen-Moves, Abschnittssortierung/-umnummerierung, Projekteinstellungen, Circuit-/CircuitDeviceRow-Struktur, Gerätezeilen-Moves,
DistributionBoard-Struktur, ProjectDevice-Struktur und -Zeilensynchronisierung Abschnittssortierung/-umnummerierung, DistributionBoard-Struktur,
sowie Geschoss/Raum verwenden dabei ProjectDevice-Struktur und -Zeilensynchronisierung sowie Geschoss/Raum verwenden
dabei
`project-command-transaction.persistence.ts` als gemeinsame äußere `project-command-transaction.persistence.ts` als gemeinsame äußere
Transaktionsgrenze. Sie führt Fachänderung, Revisions-Append und Transaktionsgrenze. Sie führt Fachänderung, Revisions-Append und
History-Transition in fester Reihenfolge innerhalb derselben SQLite-Transaktion History-Transition in fester Reihenfolge innerhalb derselben SQLite-Transaktion
+4 -4
View File
@@ -346,10 +346,10 @@ 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
- compatible structure, device-row move, section reorder/renumber, - compatible Circuit/ProjectDevice update, project-settings, structure,
ProjectDevice row-sync and project-location stores share one tested device-row move, section reorder/renumber, ProjectDevice row-sync and
transaction wrapper for domain mutation, revision append and history project-location stores share one tested transaction wrapper for domain
transition 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
@@ -18,8 +18,7 @@ import {
toCircuitPatchValues, toCircuitPatchValues,
type CircuitPatchPersistenceInput, type CircuitPatchPersistenceInput,
} from "./circuit.persistence.js"; } from "./circuit.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitRow = typeof circuits.$inferSelect; type CircuitRow = typeof circuits.$inferSelect;
@@ -31,81 +30,74 @@ export class CircuitProjectCommandRepository
executeUpdate(input: ExecuteCircuitUpdateCommandInput) { executeUpdate(input: ExecuteCircuitUpdateCommandInput) {
assertCircuitUpdateProjectCommand(input.command); assertCircuitUpdateProjectCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const current = tx this.database,
.select() input,
.from(circuits) (tx) => this.applyCommand(tx, input)
.where(eq(circuits.id, input.command.payload.circuitId)) );
.get(); }
if (!current) {
throw new Error("Invalid circuit id.");
}
const owningList = tx private applyCommand(
.select({ id: circuitLists.id }) tx: AppDatabase,
.from(circuitLists) input: ExecuteCircuitUpdateCommandInput
.where( ) {
and( const current = tx
eq(circuitLists.id, current.circuitListId), .select()
eq(circuitLists.projectId, input.projectId) .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) { .get();
throw new Error("Circuit does not belong to project."); if (!owningList) {
} throw new Error("Circuit does not belong to project.");
}
const patch = Object.fromEntries( const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [ input.command.payload.changes.map((change) => [
change.field, change.field,
change.value, change.value,
]) ])
) as CircuitPatchPersistenceInput; ) as CircuitPatchPersistenceInput;
this.assertSectionInCircuitList(tx, current, patch.sectionId); this.assertSectionInCircuitList(tx, current, patch.sectionId);
this.assertUniqueEquipmentIdentifier( this.assertUniqueEquipmentIdentifier(
tx, tx,
current, current,
patch.equipmentIdentifier patch.equipmentIdentifier
); );
const inversePatch = Object.fromEntries( const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [ input.command.payload.changes.map((change) => [
change.field, change.field,
getCircuitFieldValue(current, change.field), getCircuitFieldValue(current, change.field),
]) ])
) as CircuitUpdatePatch; ) as CircuitUpdatePatch;
const inverse = createCircuitUpdateProjectCommand( const inverse = createCircuitUpdateProjectCommand(
current.id, current.id,
inversePatch inversePatch
); );
const update = tx const update = tx
.update(circuits) .update(circuits)
.set(toCircuitPatchValues(patch)) .set(toCircuitPatchValues(patch))
.where(eq(circuits.id, current.id)) .where(eq(circuits.id, current.id))
.run(); .run();
if (update.changes !== 1) { if (update.changes !== 1) {
throw new Error("Circuit changed before command execution."); throw new Error("Circuit changed before command execution.");
} }
const revision = appendProjectRevision(tx, { return inverse;
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 assertSectionInCircuitList( private assertSectionInCircuitList(
@@ -12,8 +12,7 @@ import type {
} from "../../domain/ports/project-device-project-command.store.js"; } from "../../domain/ports/project-device-project-command.store.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type ProjectDeviceRow = typeof projectDevices.$inferSelect; type ProjectDeviceRow = typeof projectDevices.$inferSelect;
@@ -25,76 +24,70 @@ export class ProjectDeviceProjectCommandRepository
executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) { executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) {
assertProjectDeviceUpdateProjectCommand(input.command); assertProjectDeviceUpdateProjectCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const current = tx this.database,
.select() input,
.from(projectDevices) (tx) => this.applyCommand(tx, input)
.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 patch = Object.fromEntries( private applyCommand(
input.command.payload.changes.map((change) => [ tx: AppDatabase,
change.field, input: ExecuteProjectDeviceUpdateCommandInput
change.value, ) {
]) const current = tx
) as ProjectDeviceUpdatePatch; .select()
const inversePatch = Object.fromEntries( .from(projectDevices)
input.command.payload.changes.map((change) => [ .where(
change.field, and(
getProjectDeviceFieldValue(current, change.field), eq(
]) projectDevices.id,
) as ProjectDeviceUpdatePatch; input.command.payload.projectDeviceId
const inverse = createProjectDeviceUpdateProjectCommand( ),
current.id, eq(projectDevices.projectId, input.projectId)
inversePatch )
)
.get();
if (!current) {
throw new Error(
"Project device does not belong to project."
); );
}
const updated = tx const patch = Object.fromEntries(
.update(projectDevices) input.command.payload.changes.map((change) => [
.set(patch) change.field,
.where( change.value,
and( ])
eq(projectDevices.id, current.id), ) as ProjectDeviceUpdatePatch;
eq(projectDevices.projectId, input.projectId) 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) { .run();
throw new Error( if (updated.changes !== 1) {
"Project device changed before command execution." throw new Error(
); "Project device changed before command execution."
} );
}
const revision = appendProjectRevision(tx, { return inverse;
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 };
});
} }
} }
@@ -9,8 +9,7 @@ import type {
} from "../../domain/ports/project-settings-project-command.store.js"; } from "../../domain/ports/project-settings-project-command.store.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.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 ProjectSettingsProjectCommandRepository export class ProjectSettingsProjectCommandRepository
implements ProjectSettingsProjectCommandStore implements ProjectSettingsProjectCommandStore
@@ -19,53 +18,47 @@ export class ProjectSettingsProjectCommandRepository
executeUpdate(input: ExecuteProjectSettingsUpdateCommandInput) { executeUpdate(input: ExecuteProjectSettingsUpdateCommandInput) {
assertProjectSettingsUpdateProjectCommand(input.command); assertProjectSettingsUpdateProjectCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const current = tx this.database,
.select() input,
.from(projects) (tx) => this.applyCommand(tx, input)
.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 inverse = createProjectSettingsUpdateProjectCommand({ private applyCommand(
singlePhaseVoltageV: current.singlePhaseVoltageV, tx: AppDatabase,
threePhaseVoltageV: current.threePhaseVoltageV, input: ExecuteProjectSettingsUpdateCommandInput
}); ) {
const updated = tx const current = tx
.update(projects) .select()
.set(input.command.payload) .from(projects)
.where(eq(projects.id, input.projectId)) .where(eq(projects.id, input.projectId))
.run(); .get();
if (updated.changes !== 1) { if (!current) {
throw new Error("Project changed before settings update."); 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, { const inverse = createProjectSettingsUpdateProjectCommand({
projectId: input.projectId, singlePhaseVoltageV: current.singlePhaseVoltageV,
expectedRevision: input.expectedRevision, threePhaseVoltageV: current.threePhaseVoltageV,
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 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;
} }
} }