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. `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 - 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 `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
+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 `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, Die kompatiblen Multi-Write-Stores für Circuit-/CircuitDeviceRow-Struktur,
DistributionBoard, ProjectDevice sowie Geschoss/Raum verwenden dabei 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
+4 -3
View File
@@ -346,9 +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
- structural Circuit, CircuitDeviceRow, DistributionBoard, ProjectDevice and - compatible structure, device-row move, section reorder/renumber,
project-location stores share one tested transaction wrapper for domain ProjectDevice row-sync and project-location stores share one tested
mutation, revision append and history transition 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
@@ -19,8 +19,7 @@ import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js"; import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
interface PersistedMoveRow { interface PersistedMoveRow {
id: string; id: string;
@@ -36,29 +35,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
execute(input: ExecuteCircuitDeviceRowMoveCommandInput) { execute(input: ExecuteCircuitDeviceRowMoveCommandInput) {
this.assertSupportedCommand(input.command); this.assertSupportedCommand(input.command);
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 assertSupportedCommand( private assertSupportedCommand(
@@ -12,8 +12,7 @@ import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js"; import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.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 CircuitSectionRenumberProjectCommandRepository export class CircuitSectionRenumberProjectCommandRepository
implements CircuitSectionRenumberProjectCommandStore implements CircuitSectionRenumberProjectCommandStore
@@ -23,190 +22,184 @@ export class CircuitSectionRenumberProjectCommandRepository
execute(input: ExecuteCircuitSectionRenumberCommandInput) { execute(input: ExecuteCircuitSectionRenumberCommandInput) {
assertCircuitSectionRenumberProjectCommand(input.command); assertCircuitSectionRenumberProjectCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const section = tx this.database,
.select({ input,
id: circuitSections.id, (tx) => this.applyCommand(tx, input)
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 private applyCommand(
.select({ tx: AppDatabase,
id: circuits.id, input: ExecuteCircuitSectionRenumberCommandInput
circuitListId: circuits.circuitListId, ) {
equipmentIdentifier: circuits.equipmentIdentifier, const section = tx
}) .select({
.from(circuits) id: circuitSections.id,
.where(eq(circuits.sectionId, section.id)) circuitListId: circuitSections.circuitListId,
.all(); projectId: circuitLists.projectId,
const assignments = input.command.payload.assignments; })
.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 ( if (
sectionCircuits.length !== assignments.length || !circuit ||
sectionCircuits.some( circuit.equipmentIdentifier !==
(circuit) => assignment.expectedEquipmentIdentifier
circuit.circuitListId !== section.circuitListId
)
) { ) {
throw new Error( 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 const listCircuits = tx
.select({ .select({
id: circuits.id, id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier, equipmentIdentifier: circuits.equipmentIdentifier,
}) })
.from(circuits) .from(circuits)
.where(eq(circuits.circuitListId, section.circuitListId)) .where(eq(circuits.circuitListId, section.circuitListId))
.all(); .all();
const sectionCircuitIds = new Set( const sectionCircuitIds = new Set(
assignments.map((assignment) => assignment.circuitId) 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
)
)
) )
); .run();
const conflictingCircuit = listCircuits.find( if (updated.changes !== 1) {
(circuit) =>
!sectionCircuitIds.has(circuit.id) &&
targetIdentifiers.has(circuit.equipmentIdentifier)
);
if (conflictingCircuit) {
throw new Error( throw new Error(
"Target equipment identifier already exists in circuit list." "Circuit changed during section renumber execution."
); );
} }
}
const inverse = createCircuitSectionRenumberProjectCommand( for (const assignment of assignments) {
section.id, if (
assignments.map((assignment) => ({ assignment.expectedEquipmentIdentifier ===
circuitId: assignment.circuitId, assignment.targetEquipmentIdentifier
expectedEquipmentIdentifier: ) {
continue;
}
const temporaryIdentifier = temporaryIdentifiers.get(
assignment.circuitId
)!;
const updated = tx
.update(circuits)
.set({
equipmentIdentifier:
assignment.targetEquipmentIdentifier, assignment.targetEquipmentIdentifier,
targetEquipmentIdentifier: })
assignment.expectedEquipmentIdentifier, .where(
})) and(
); eq(circuits.id, assignment.circuitId),
const temporaryIdentifiers = this.createTemporaryIdentifiers( eq(circuits.sectionId, section.id),
section.id, eq(circuits.circuitListId, section.circuitListId),
assignments, eq(
listCircuits.map((circuit) => circuit.equipmentIdentifier) circuits.equipmentIdentifier,
); temporaryIdentifier
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
)
) )
) )
.run(); )
if (updated.changes !== 1) { .run();
throw new Error( if (updated.changes !== 1) {
"Circuit changed during section renumber execution." throw new Error(
); "Circuit changed during final section renumber execution."
} );
} }
}
for (const assignment of assignments) { return inverse;
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 };
});
} }
private createTemporaryIdentifiers( private createTemporaryIdentifiers(
@@ -21,8 +21,7 @@ import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js"; import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type ReorderHistoryCommand = type ReorderHistoryCommand =
| CircuitSectionReorderProjectCommand | CircuitSectionReorderProjectCommand
@@ -36,54 +35,48 @@ export class CircuitSectionReorderProjectCommandRepository
execute(input: ExecuteCircuitSectionReorderCommandInput) { execute(input: ExecuteCircuitSectionReorderCommandInput) {
this.assertSupportedCommand(input.command); this.assertSupportedCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const commandSections = this.getCommandSections(input.command); this.database,
const inverseSections = commandSections.map((section) => { input,
this.assertCompleteCurrentSection( (tx) => this.applyCommand(tx, input)
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) { private applyCommand(
this.applyAssignments(tx, section); tx: AppDatabase,
} input: ExecuteCircuitSectionReorderCommandInput
) {
const inverse = const commandSections = this.getCommandSections(input.command);
input.command.type === circuitSectionReorderCommandType const inverseSections = commandSections.map((section) => {
? createCircuitSectionReorderProjectCommand( this.assertCompleteCurrentSection(
inverseSections[0].sectionId, tx,
inverseSections[0].assignments input.projectId,
) section
: createCircuitSectionsReorderProjectCommand( );
inverseSections return {
); sectionId: section.sectionId,
const revision = appendProjectRevision(tx, { assignments: section.assignments.map((assignment) => ({
projectId: input.projectId, circuitId: assignment.circuitId,
expectedRevision: input.expectedRevision, expectedSortOrder: assignment.targetSortOrder,
source: input.source, targetSortOrder: assignment.expectedSortOrder,
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 };
}); });
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( private assertSupportedCommand(
@@ -15,8 +15,7 @@ import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.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 CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect; type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
@@ -28,116 +27,110 @@ export class ProjectDeviceRowSyncProjectCommandRepository
execute(input: ExecuteProjectDeviceRowSyncCommandInput) { execute(input: ExecuteProjectDeviceRowSyncCommandInput) {
assertProjectDeviceRowSyncProjectCommand(input.command); assertProjectDeviceRowSyncProjectCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const projectDevice = tx this.database,
.select({ id: projectDevices.id }) input,
.from(projectDevices) (tx) => this.applyCommand(tx, input)
.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 rowIds = input.command.payload.rows.map( private applyCommand(
(row) => row.rowId 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, const rowIds = input.command.payload.rows.map(
projectId: circuitLists.projectId, (row) => row.rowId
}) );
.from(circuitDeviceRows) const persistedRows = tx
.innerJoin( .select({
circuits, row: circuitDeviceRows,
eq(circuits.id, circuitDeviceRows.circuitId) projectId: circuitLists.projectId,
) })
.innerJoin( .from(circuitDeviceRows)
circuitLists, .innerJoin(
eq(circuitLists.id, circuits.circuitListId) circuits,
) eq(circuits.id, circuitDeviceRows.circuitId)
.where(inArray(circuitDeviceRows.id, rowIds)) )
.all(); .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 ( if (
persistedRows.length !== rowIds.length || !persisted ||
persistedRows.some( !snapshotMatchesRow(assignment.expected, persisted)
(persisted) => persisted.projectId !== input.projectId
)
) { ) {
throw new Error( throw new Error(
"One or more sync rows do not belong to project." "Project-device row changed before sync execution."
); );
} }
}
const persistedById = new Map( const inverse = createProjectDeviceRowSyncProjectCommand(
persistedRows.map((persisted) => [ projectDevice.id,
persisted.row.id, invertProjectDeviceRowSyncOperation(
persisted.row, input.command.payload.operation
]) ),
); input.command.payload.rows.map((row) => ({
for (const assignment of input.command.payload.rows) { rowId: row.rowId,
const persisted = persistedById.get(assignment.rowId); expected: row.target,
if ( target: row.expected,
!persisted || }))
!snapshotMatchesRow(assignment.expected, persisted) );
) {
throw new Error( for (const assignment of input.command.payload.rows) {
"Project-device row changed before sync execution." 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( return inverse;
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 };
});
} }
} }