Centralize device row update transactions

This commit is contained in:
2026-07-26 11:28:06 +02:00
parent e4d22caf09
commit 214ad728cd
5 changed files with 142 additions and 104 deletions
+3 -4
View File
@@ -21,11 +21,10 @@ 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 command stores for Circuit and ProjectDevice field updates, - All supported runtime project-command stores 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. Its applied
forward-command variant preserves derived CircuitDeviceRow override metadata.
- 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`.
+4 -6
View File
@@ -84,15 +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 kompatiblen Command-Stores für Circuit- und ProjectDevice-Feldänderungen, Alle unterstützten Runtime-Project-Command-Stores verwenden dabei
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 `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
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. 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
+3 -4
View File
@@ -346,10 +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
- compatible Circuit/ProjectDevice update, project-settings, structure, - all supported runtime project-command stores share one tested transaction
device-row move, section reorder/renumber, ProjectDevice row-sync and wrapper for domain mutation, revision append and history transition; its
project-location stores share one tested transaction wrapper for domain applied-forward variant retains derived CircuitDeviceRow override metadata
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 {
toCircuitDeviceRowPatchValues, toCircuitDeviceRowPatchValues,
type CircuitDeviceRowPatchInput, type CircuitDeviceRowPatchInput,
} from "./circuit-device-row.persistence.js"; } from "./circuit-device-row.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect; type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
@@ -34,99 +33,95 @@ export class CircuitDeviceRowProjectCommandRepository
executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) { executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) {
assertCircuitDeviceRowUpdateProjectCommand(input.command); assertCircuitDeviceRowUpdateProjectCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransactionWithAppliedForward(
const current = tx this.database,
.select() input,
.from(circuitDeviceRows) (tx) => this.applyCommand(tx, input)
.where(eq(circuitDeviceRows.id, input.command.payload.rowId)) );
.get(); }
if (!current) {
throw new Error("Invalid device row id.");
}
const circuit = tx private applyCommand(
.select({ circuitListId: circuits.circuitListId }) tx: AppDatabase,
.from(circuits) input: ExecuteCircuitDeviceRowUpdateCommandInput
.where(eq(circuits.id, current.circuitId)) ) {
.get(); const current = tx
const owningList = circuit .select()
? tx .from(circuitDeviceRows)
.select({ id: circuitLists.id }) .where(eq(circuitDeviceRows.id, input.command.payload.rowId))
.from(circuitLists) .get();
.where( if (!current) {
and( throw new Error("Invalid device row id.");
eq(circuitLists.id, circuit.circuitListId), }
eq(circuitLists.projectId, input.projectId)
) 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; .get()
if (!owningList) { : null;
throw new Error("Circuit device row does not belong to project."); if (!owningList) {
} throw new Error("Circuit device row 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 CircuitDeviceRowPatchInput; ) as CircuitDeviceRowPatchInput;
this.assertLinkedProjectDevice(tx, input.projectId, patch); this.assertLinkedProjectDevice(tx, input.projectId, patch);
this.assertRoom(tx, input.projectId, patch); this.assertRoom(tx, input.projectId, patch);
const overriddenFields = deriveOverriddenFieldsForLocalEdit( const overriddenFields = deriveOverriddenFieldsForLocalEdit(
current, current,
patch patch
); );
if ( if (
overriddenFields !== undefined && overriddenFields !== undefined &&
patch.overriddenFields === undefined patch.overriddenFields === undefined
) { ) {
patch.overriddenFields = overriddenFields; patch.overriddenFields = overriddenFields;
} }
const appliedForward = createCircuitDeviceRowUpdateProjectCommand( const appliedForward = createCircuitDeviceRowUpdateProjectCommand(
current.id, current.id,
patch as CircuitDeviceRowUpdatePatch patch as CircuitDeviceRowUpdatePatch
); );
const inversePatch = Object.fromEntries( const inversePatch = Object.fromEntries(
appliedForward.payload.changes.map((change) => [ appliedForward.payload.changes.map((change) => [
change.field, change.field,
getCircuitDeviceRowFieldValue(current, change.field), getCircuitDeviceRowFieldValue(current, change.field),
]) ])
) as CircuitDeviceRowUpdatePatch; ) as CircuitDeviceRowUpdatePatch;
const inverse = createCircuitDeviceRowUpdateProjectCommand( const inverse = createCircuitDeviceRowUpdateProjectCommand(
current.id, current.id,
inversePatch inversePatch
); );
const update = tx const update = tx
.update(circuitDeviceRows) .update(circuitDeviceRows)
.set(toCircuitDeviceRowPatchValues(patch)) .set(toCircuitDeviceRowPatchValues(patch))
.where(eq(circuitDeviceRows.id, current.id)) .where(eq(circuitDeviceRows.id, current.id))
.run(); .run();
if (update.changes !== 1) { if (update.changes !== 1) {
throw new Error("Circuit device row changed before command execution."); throw new Error("Circuit device row changed before command execution.");
} }
const revision = appendProjectRevision(tx, { return {
projectId: input.projectId, forward: appliedForward,
expectedRevision: input.expectedRevision, inverse,
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 };
});
} }
private assertLinkedProjectDevice( private assertLinkedProjectDevice(
@@ -19,6 +19,14 @@ export interface ProjectCommandTransactionInput<
command: Command; command: Command;
} }
export interface AppliedProjectCommand<
Forward extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>,
> {
forward: Forward;
inverse: Inverse;
}
export function executeProjectCommandTransaction< export function executeProjectCommandTransaction<
Command extends SerializedProjectCommand<unknown>, Command extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>, Inverse extends SerializedProjectCommand<unknown>,
@@ -26,16 +34,55 @@ export function executeProjectCommandTransaction<
database: AppDatabase, database: AppDatabase,
input: ProjectCommandTransactionInput<Command>, input: ProjectCommandTransactionInput<Command>,
applyCommand: (transaction: AppDatabase) => Inverse 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<unknown>,
Forward extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>,
>(
database: AppDatabase,
input: ProjectCommandTransactionInput<Command>,
applyCommand: (
transaction: AppDatabase
) => AppliedProjectCommand<Forward, Inverse>
): { revision: AppendedProjectRevision; inverse: Inverse } {
return executeAppliedProjectCommandTransaction(
database,
input,
applyCommand
);
}
function executeAppliedProjectCommandTransaction<
Command extends SerializedProjectCommand<unknown>,
Forward extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>,
>(
database: AppDatabase,
input: ProjectCommandTransactionInput<Command>,
applyCommand: (
transaction: AppDatabase
) => AppliedProjectCommand<Forward, Inverse>
): { revision: AppendedProjectRevision; inverse: Inverse } { ): { revision: AppendedProjectRevision; inverse: Inverse } {
return database.transaction((tx) => { return database.transaction((tx) => {
const inverse = applyCommand(tx); const { forward, inverse } = applyCommand(tx);
const revision = appendProjectRevision(tx, { const revision = appendProjectRevision(tx, {
projectId: input.projectId, projectId: input.projectId,
expectedRevision: input.expectedRevision, expectedRevision: input.expectedRevision,
source: input.source, source: input.source,
description: input.description, description: input.description,
actorId: input.actorId, actorId: input.actorId,
forward: input.command, forward,
inverse, inverse,
}); });
applyProjectHistoryTransition(tx, { applyProjectHistoryTransition(tx, {