Make bulk row moves atomic

This commit is contained in:
2026-07-23 17:22:56 +02:00
parent e4fd7e0df6
commit 9cc3b7c08c
5 changed files with 310 additions and 116 deletions
@@ -26,6 +26,18 @@ export interface CircuitDeviceRowUpdateInput {
overriddenFields?: string;
}
export interface CircuitDeviceRowsBulkMoveInput {
rows: Array<{ id: string; expectedCircuitId: string }>;
targetCircuitId?: string;
createTargetCircuit?: {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
};
}
function toUpdateValues(input: CircuitDeviceRowUpdateInput) {
return {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
@@ -259,4 +271,138 @@ export class CircuitDeviceRowRepository {
})
.where(eq(circuitDeviceRows.id, rowId));
}
moveRowsTransactional(input: CircuitDeviceRowsBulkMoveInput) {
if (input.rows.length === 0) {
throw new Error("Keine Gerätezeilen zum Verschieben angegeben.");
}
if (Boolean(input.targetCircuitId) === Boolean(input.createTargetCircuit)) {
throw new Error("Für die Mehrfachverschiebung muss genau ein Ziel angegeben werden.");
}
const rowIds = input.rows.map((row) => row.id);
if (new Set(rowIds).size !== rowIds.length) {
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
}
const createdCircuitId = input.createTargetCircuit ? crypto.randomUUID() : undefined;
const targetCircuitId = input.targetCircuitId ?? createdCircuitId!;
// better-sqlite3 transactions must remain synchronous. Every row assignment,
// reserve-state update and optional target creation is committed or rolled back
// as one operation.
db.transaction((tx) => {
const persistedRows = tx
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (persistedRows.length !== input.rows.length) {
throw new Error("Eine oder mehrere Gerätezeilen sind ungültig.");
}
const persistedRowsById = new Map(persistedRows.map((row) => [row.id, row]));
for (const expectedRow of input.rows) {
if (persistedRowsById.get(expectedRow.id)?.circuitId !== expectedRow.expectedCircuitId) {
throw new Error("Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert.");
}
}
let targetCircuit: { id: string; circuitListId: string };
if (input.createTargetCircuit) {
tx
.insert(circuits)
.values({
id: targetCircuitId,
circuitListId: input.createTargetCircuit.circuitListId,
sectionId: input.createTargetCircuit.sectionId,
equipmentIdentifier: input.createTargetCircuit.equipmentIdentifier,
displayName: input.createTargetCircuit.displayName,
sortOrder: input.createTargetCircuit.sortOrder,
isReserve: 0,
})
.run();
targetCircuit = {
id: targetCircuitId,
circuitListId: input.createTargetCircuit.circuitListId,
};
} else {
const [persistedTargetCircuit] = tx
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
.from(circuits)
.where(eq(circuits.id, targetCircuitId))
.limit(1)
.all();
if (!persistedTargetCircuit) {
throw new Error("Der Zielstromkreis ist ungültig.");
}
targetCircuit = persistedTargetCircuit;
}
const sourceCircuitIds = [...new Set(input.rows.map((row) => row.expectedCircuitId))];
const sourceCircuits = tx
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
.from(circuits)
.where(inArray(circuits.id, sourceCircuitIds))
.all();
if (sourceCircuits.length !== sourceCircuitIds.length) {
throw new Error("Einer oder mehrere Quellstromkreise sind ungültig.");
}
if (sourceCircuits.some((circuit) => circuit.circuitListId !== targetCircuit.circuitListId)) {
throw new Error("Alle verschobenen Zeilen müssen zur Stromkreisliste des Ziels gehören.");
}
const movedRowIdSet = new Set(rowIds);
const retainedTargetRows = tx
.select({ id: circuitDeviceRows.id, sortOrder: circuitDeviceRows.sortOrder })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, targetCircuitId))
.all()
.filter((row) => !movedRowIdSet.has(row.id));
const lastTargetSortOrder = retainedTargetRows.reduce(
(highest, row) => Math.max(highest, row.sortOrder),
0
);
for (let index = 0; index < input.rows.length; index += 1) {
const row = input.rows[index];
const result = tx
.update(circuitDeviceRows)
.set({
circuitId: targetCircuitId,
sortOrder: lastTargetSortOrder + (index + 1) * 10,
})
.where(
and(
eq(circuitDeviceRows.id, row.id),
eq(circuitDeviceRows.circuitId, row.expectedCircuitId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert.");
}
}
for (const sourceCircuitId of sourceCircuitIds) {
const remainingRows = tx
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, sourceCircuitId))
.all();
tx
.update(circuits)
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
.where(eq(circuits.id, sourceCircuitId))
.run();
}
tx.update(circuits).set({ isReserve: 0 }).where(eq(circuits.id, targetCircuitId)).run();
});
return {
movedRowIds: rowIds,
targetCircuitId,
createdCircuitId,
};
}
}
+22 -71
View File
@@ -330,7 +330,7 @@ export class CircuitWriteService {
circuitListId: sourceCircuit.circuitListId,
sectionId: section.id,
equipmentIdentifier: nextIdentifier,
displayName: "New circuit",
displayName: "Neuer Stromkreis",
sortOrder: nextSortOrder,
isReserve: false,
});
@@ -436,7 +436,17 @@ export class CircuitWriteService {
throw new Error("Invalid target circuit id.");
}
// Bulk placeholder move creates exactly one new circuit as common target.
// Bulk placeholder move prepares exactly one new circuit as common target.
// Its actual creation happens together with the row moves in one transaction.
let createTargetCircuit:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
if (!targetCircuit) {
if (!input.targetSectionId || !input.createNewCircuit) {
throw new Error("Invalid move target.");
@@ -446,86 +456,27 @@ export class CircuitWriteService {
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
const nextSortOrder =
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const createdId = await this.circuitRepository.create({
createTargetCircuit = {
circuitListId: referenceSourceCircuit.circuitListId,
sectionId: section.id,
equipmentIdentifier: nextIdentifier,
displayName: "New circuit",
displayName: "Neuer Stromkreis",
sortOrder: nextSortOrder,
isReserve: false,
});
targetCircuit = await this.circuitRepository.findById(createdId);
if (!targetCircuit) {
throw new Error("Failed to create target circuit.");
}
};
}
// Any source circuit emptied by bulk move is preserved as reserve circuit.
const targetCircuitListId = targetCircuit?.circuitListId ?? createTargetCircuit!.circuitListId;
for (const sourceCircuit of sourceCircuits.values()) {
if (sourceCircuit.circuitListId !== targetCircuit.circuitListId) {
if (sourceCircuit.circuitListId !== targetCircuitListId) {
throw new Error("All moved rows must belong to same circuit list as target.");
}
}
const targetCount = await this.deviceRowRepository.countByCircuit(targetCircuit.id);
let offset = 1;
for (const row of rows) {
await this.deviceRowRepository.moveToCircuit(row.id, targetCircuit.id, (targetCount + offset) * 10);
offset += 1;
}
for (const sourceCircuit of sourceCircuits.values()) {
const remaining = await this.deviceRowRepository.countByCircuit(sourceCircuit.id);
if (remaining === 0) {
await this.circuitRepository.update(sourceCircuit.id, {
sectionId: sourceCircuit.sectionId,
equipmentIdentifier: sourceCircuit.equipmentIdentifier,
displayName: sourceCircuit.displayName ?? undefined,
sortOrder: sourceCircuit.sortOrder,
protectionType: sourceCircuit.protectionType ?? undefined,
protectionRatedCurrent: sourceCircuit.protectionRatedCurrent ?? undefined,
protectionCharacteristic: sourceCircuit.protectionCharacteristic ?? undefined,
cableType: sourceCircuit.cableType ?? undefined,
cableCrossSection: sourceCircuit.cableCrossSection ?? undefined,
cableLength: sourceCircuit.cableLength ?? undefined,
rcdAssignment: sourceCircuit.rcdAssignment ?? undefined,
terminalDesignation: sourceCircuit.terminalDesignation ?? undefined,
voltage: sourceCircuit.voltage ?? undefined,
controlRequirement: sourceCircuit.controlRequirement ?? undefined,
status: sourceCircuit.status ?? undefined,
isReserve: true,
remark: sourceCircuit.remark ?? undefined,
});
}
}
if (Boolean(targetCircuit.isReserve)) {
await this.circuitRepository.update(targetCircuit.id, {
sectionId: targetCircuit.sectionId,
equipmentIdentifier: targetCircuit.equipmentIdentifier,
displayName: targetCircuit.displayName ?? undefined,
sortOrder: targetCircuit.sortOrder,
protectionType: targetCircuit.protectionType ?? undefined,
protectionRatedCurrent: targetCircuit.protectionRatedCurrent ?? undefined,
protectionCharacteristic: targetCircuit.protectionCharacteristic ?? undefined,
cableType: targetCircuit.cableType ?? undefined,
cableCrossSection: targetCircuit.cableCrossSection ?? undefined,
cableLength: targetCircuit.cableLength ?? undefined,
rcdAssignment: targetCircuit.rcdAssignment ?? undefined,
terminalDesignation: targetCircuit.terminalDesignation ?? undefined,
voltage: targetCircuit.voltage ?? undefined,
controlRequirement: targetCircuit.controlRequirement ?? undefined,
status: targetCircuit.status ?? undefined,
isReserve: false,
remark: targetCircuit.remark ?? undefined,
});
}
return {
movedRowIds: rows.map((row) => row.id),
targetCircuitId: targetCircuit.id,
createdCircuitId: input.targetCircuitId ? undefined : targetCircuit.id,
};
return this.deviceRowRepository.moveRowsTransactional({
rows: rows.map((row) => ({ id: row.id, expectedCircuitId: row.circuitId })),
targetCircuitId: targetCircuit?.id,
createTargetCircuit,
});
}
async getNextIdentifier(sectionId: string) {