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.
- 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
+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
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
+4 -4
View File
@@ -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
@@ -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(
@@ -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;
}
}
@@ -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;
}
}