diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index 9e382c4..1acdfd8 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -81,6 +81,7 @@ Intent is separated by drag source type: - circuit drag (BMK handle): - reorder circuits inside same section only - cross-section reorder is rejected + - the complete section order is validated and stored in one SQLite transaction - bulk device row move: - supported via multi-selection + drag - multi-circuit move: @@ -103,6 +104,7 @@ Cross-section device moves show confirmation-required feedback before drop. The - disabled while filters are active - undoable via command history +- each complete section order is stored atomically - until applied, sort remains view-only ## Column Configuration diff --git a/src/db/repositories/circuit.repository.ts b/src/db/repositories/circuit.repository.ts index b43c491..c3026dd 100644 --- a/src/db/repositories/circuit.repository.ts +++ b/src/db/repositories/circuit.repository.ts @@ -137,6 +137,46 @@ export class CircuitRepository { .orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier)); } + updateSortOrdersSafely(sectionId: string, orderedCircuitIds: string[]) { + if (orderedCircuitIds.length === 0) { + return; + } + if (new Set(orderedCircuitIds).size !== orderedCircuitIds.length) { + throw new Error("Stromkreise dürfen in der Reihenfolge nicht mehrfach vorkommen."); + } + + db.transaction((tx) => { + const sectionCircuits = tx + .select({ id: circuits.id }) + .from(circuits) + .where(eq(circuits.sectionId, sectionId)) + .all(); + const sectionCircuitIds = new Set(sectionCircuits.map((circuit) => circuit.id)); + if ( + sectionCircuits.length !== orderedCircuitIds.length || + orderedCircuitIds.some((circuitId) => !sectionCircuitIds.has(circuitId)) + ) { + throw new Error("Die Reihenfolge muss alle Stromkreise des Bereichs enthalten."); + } + + for (let index = 0; index < orderedCircuitIds.length; index += 1) { + const result = tx + .update(circuits) + .set({ sortOrder: (index + 1) * 10 }) + .where( + and( + eq(circuits.sectionId, sectionId), + eq(circuits.id, orderedCircuitIds[index]) + ) + ) + .run(); + if (result.changes !== 1) { + throw new Error("Ein Stromkreis wurde vor Abschluss der Sortierung verändert."); + } + } + }); + } + async updateEquipmentIdentifiersSafely( circuitListId: string, updates: Array<{ id: string; equipmentIdentifier: string }>, diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index 98ad79c..4da5cd6 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -476,32 +476,7 @@ export class CircuitWriteService { } } - for (let index = 0; index < input.orderedCircuitIds.length; index += 1) { - const circuitId = input.orderedCircuitIds[index]; - const circuit = sectionCircuits.find((entry) => entry.id === circuitId); - if (!circuit) { - throw new Error("Invalid circuit id."); - } - await this.circuitRepository.update(circuit.id, { - sectionId: circuit.sectionId, - equipmentIdentifier: circuit.equipmentIdentifier, - displayName: circuit.displayName ?? undefined, - sortOrder: (index + 1) * 10, - protectionType: circuit.protectionType ?? undefined, - protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined, - protectionCharacteristic: circuit.protectionCharacteristic ?? undefined, - cableType: circuit.cableType ?? undefined, - cableCrossSection: circuit.cableCrossSection ?? undefined, - cableLength: circuit.cableLength ?? undefined, - rcdAssignment: circuit.rcdAssignment ?? undefined, - terminalDesignation: circuit.terminalDesignation ?? undefined, - voltage: circuit.voltage ?? undefined, - controlRequirement: circuit.controlRequirement ?? undefined, - status: circuit.status ?? undefined, - isReserve: Boolean(circuit.isReserve), - remark: circuit.remark ?? undefined, - }); - } + this.circuitRepository.updateSortOrdersSafely(sectionId, input.orderedCircuitIds); return this.circuitRepository.listBySection(sectionId); } } diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts index 3514202..45d5dc5 100644 --- a/tests/circuit-write.rules.test.ts +++ b/tests/circuit-write.rules.test.ts @@ -580,7 +580,7 @@ describe("circuit write service rules", () => { }); it("reorders circuits inside one section without renumbering identifiers", async () => { - const updates: Array<{ id: string; sortOrder: number; equipmentIdentifier: string }> = []; + let safeReorder: { sectionId: string; circuitIds: string[] } | undefined; const service = new CircuitWriteService({ circuitSectionRepository: { async findById() { @@ -595,18 +595,17 @@ describe("circuit write service rules", () => { { id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 }, ] as never[]; }, - async update(id: string, payload: { sortOrder: number; equipmentIdentifier: string }) { - updates.push({ id, sortOrder: payload.sortOrder, equipmentIdentifier: payload.equipmentIdentifier }); + updateSortOrdersSafely(sectionId: string, circuitIds: string[]) { + safeReorder = { sectionId, circuitIds }; }, } as never, }); await service.reorderCircuitsInSection("s1", { orderedCircuitIds: ["c3", "c1", "c2"] }); - assert.deepEqual(updates, [ - { id: "c3", sortOrder: 10, equipmentIdentifier: "-2F5" }, - { id: "c1", sortOrder: 20, equipmentIdentifier: "-2F7" }, - { id: "c2", sortOrder: 30, equipmentIdentifier: "-2F9" }, - ]); + assert.deepEqual(safeReorder, { + sectionId: "s1", + circuitIds: ["c3", "c1", "c2"], + }); }); it("safe section identifier bulk update validates full section set (undo safety)", async () => { @@ -698,6 +697,61 @@ describe("circuit write service rules", () => { } }); + it("safe circuit reorder uses one synchronous transaction callback", () => { + const repository = new CircuitRepository(); + const originalTransaction = (db as unknown as { transaction: unknown }).transaction; + + let callbackReturnedPromise = false; + const sortOrders: number[] = []; + (db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => { + const fakeTx = { + select() { + return { + from() { + return { + where() { + return { + all() { + return [{ id: "c1" }, { id: "c2" }, { id: "c3" }]; + }, + }; + }, + }; + }, + }; + }, + update() { + return { + set(values: { sortOrder: number }) { + sortOrders.push(values.sortOrder); + return { + where() { + return { + run() { + return { changes: 1 }; + }, + }; + }, + }; + }, + }; + }, + }; + const callbackResult = cb(fakeTx); + callbackReturnedPromise = Boolean( + callbackResult && typeof (callbackResult as Promise).then === "function" + ); + }; + + try { + repository.updateSortOrdersSafely("s1", ["c3", "c1", "c2"]); + assert.equal(callbackReturnedPromise, false); + assert.deepEqual(sortOrders, [10, 20, 30]); + } finally { + (db as unknown as { transaction: unknown }).transaction = originalTransaction; + } + }); + it("bulk device row move uses one synchronous transaction callback", () => { const repository = new CircuitDeviceRowRepository(); const originalTransaction = (db as unknown as { transaction: unknown }).transaction;