Reuse command transaction boundary

This commit is contained in:
2026-07-26 11:21:01 +02:00
parent 266bdb4749
commit 0f306d9a90
7 changed files with 314 additions and 349 deletions
+2 -1
View File
@@ -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
+4 -2
View File
@@ -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
+4 -3
View File
@@ -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
@@ -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(
@@ -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(
@@ -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(
@@ -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;
}
}