Compare commits

...

2 Commits

Author SHA1 Message Date
jappel eb945a9622 Make single row moves atomic 2026-07-23 17:25:28 +02:00
jappel 9cc3b7c08c Make bulk row moves atomic 2026-07-23 17:22:56 +02:00
5 changed files with 403 additions and 225 deletions
+1 -1
View File
@@ -75,6 +75,7 @@ Intent is separated by drag source type:
- drop to placeholder -> create new target circuit and move row(s) - drop to placeholder -> create new target circuit and move row(s)
- crossing a section boundary requires explicit confirmation - crossing a section boundary requires explicit confirmation
- phase type, category, linked project device and local row values remain unchanged - phase type, category, linked project device and local row values remain unchanged
- target creation, row assignments and reserve-state updates use one SQLite transaction
- circuit drag (BMK handle): - circuit drag (BMK handle):
- reorder circuits inside same section only - reorder circuits inside same section only
- cross-section reorder is rejected - cross-section reorder is rejected
@@ -125,4 +126,3 @@ Current limitations:
- session-local only - session-local only
- no persisted history across browser reload - no persisted history across browser reload
- some multi-step backend flows are not fully transaction-hardened end-to-end
@@ -5,7 +5,6 @@
- No global cross-project device library workflow is implemented yet. - No global cross-project device library workflow is implemented yet.
- Final electrical sizing logic is not implemented yet. - Final electrical sizing logic is not implemented yet.
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet. - No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
- Bulk device-row move flow is command-based but not fully transaction-hardened end-to-end across all affected circuits.
- Sorting is view-only until users explicitly apply sorted order. - Sorting is view-only until users explicitly apply sorted order.
- Cross-section circuit drag-reorder is intentionally blocked. - Cross-section circuit drag-reorder is intentionally blocked.
- Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline. - Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline.
@@ -26,6 +26,18 @@ export interface CircuitDeviceRowUpdateInput {
overriddenFields?: string; 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) { function toUpdateValues(input: CircuitDeviceRowUpdateInput) {
return { return {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null, linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
@@ -259,4 +271,138 @@ export class CircuitDeviceRowRepository {
}) })
.where(eq(circuitDeviceRows.id, rowId)); .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,
};
}
} }
+43 -130
View File
@@ -316,7 +316,18 @@ export class CircuitWriteService {
throw new Error("Invalid target circuit id."); throw new Error("Invalid target circuit id.");
} }
// Placeholder-target move creates a new circuit explicitly; no implicit renumbering of others. let createTargetCircuit:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
// Placeholder-target move prepares a new circuit explicitly. Its creation and
// the row assignment are committed together without renumbering other circuits.
if (!targetCircuit) { if (!targetCircuit) {
if (!input.targetSectionId || !input.createNewCircuit) { if (!input.targetSectionId || !input.createNewCircuit) {
throw new Error("Invalid move target."); throw new Error("Invalid move target.");
@@ -326,76 +337,27 @@ export class CircuitWriteService {
const sectionCircuits = await this.circuitRepository.listBySection(section.id); const sectionCircuits = await this.circuitRepository.listBySection(section.id);
const nextSortOrder = const nextSortOrder =
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10; sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const createdId = await this.circuitRepository.create({ createTargetCircuit = {
circuitListId: sourceCircuit.circuitListId, circuitListId: sourceCircuit.circuitListId,
sectionId: section.id, sectionId: section.id,
equipmentIdentifier: nextIdentifier, equipmentIdentifier: nextIdentifier,
displayName: "New circuit", displayName: "Neuer Stromkreis",
sortOrder: nextSortOrder, sortOrder: nextSortOrder,
isReserve: false, };
});
targetCircuit = await this.circuitRepository.findById(createdId);
if (!targetCircuit) {
throw new Error("Failed to create target circuit.");
}
} }
if (targetCircuit.circuitListId !== sourceCircuit.circuitListId) { if (targetCircuit && targetCircuit.circuitListId !== sourceCircuit.circuitListId) {
throw new Error("Target circuit does not belong to same circuit list."); throw new Error("Target circuit does not belong to same circuit list.");
} }
if (targetCircuit.id === sourceCircuit.id) { if (targetCircuit?.id === sourceCircuit.id) {
return this.deviceRowRepository.findById(rowId); return this.deviceRowRepository.findById(rowId);
} }
const targetCount = await this.deviceRowRepository.countByCircuit(targetCircuit.id); this.deviceRowRepository.moveRowsTransactional({
await this.deviceRowRepository.moveToCircuit(rowId, targetCircuit.id, (targetCount + 1) * 10); rows: [{ id: row.id, expectedCircuitId: row.circuitId }],
targetCircuitId: targetCircuit?.id,
const sourceRemaining = await this.deviceRowRepository.countByCircuit(sourceCircuit.id); createTargetCircuit,
// Source circuit becomes reserve when all rows are moved away. });
if (sourceRemaining === 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,
});
}
// Target circuit is no longer reserve once it receives moved rows.
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 this.deviceRowRepository.findById(rowId); return this.deviceRowRepository.findById(rowId);
} }
@@ -436,7 +398,17 @@ export class CircuitWriteService {
throw new Error("Invalid target circuit id."); 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 (!targetCircuit) {
if (!input.targetSectionId || !input.createNewCircuit) { if (!input.targetSectionId || !input.createNewCircuit) {
throw new Error("Invalid move target."); throw new Error("Invalid move target.");
@@ -446,86 +418,27 @@ export class CircuitWriteService {
const sectionCircuits = await this.circuitRepository.listBySection(section.id); const sectionCircuits = await this.circuitRepository.listBySection(section.id);
const nextSortOrder = const nextSortOrder =
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10; sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const createdId = await this.circuitRepository.create({ createTargetCircuit = {
circuitListId: referenceSourceCircuit.circuitListId, circuitListId: referenceSourceCircuit.circuitListId,
sectionId: section.id, sectionId: section.id,
equipmentIdentifier: nextIdentifier, equipmentIdentifier: nextIdentifier,
displayName: "New circuit", displayName: "Neuer Stromkreis",
sortOrder: nextSortOrder, 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()) { 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."); throw new Error("All moved rows must belong to same circuit list as target.");
} }
} }
const targetCount = await this.deviceRowRepository.countByCircuit(targetCircuit.id); return this.deviceRowRepository.moveRowsTransactional({
let offset = 1; rows: rows.map((row) => ({ id: row.id, expectedCircuitId: row.circuitId })),
for (const row of rows) { targetCircuitId: targetCircuit?.id,
await this.deviceRowRepository.moveToCircuit(row.id, targetCircuit.id, (targetCount + offset) * 10); createTargetCircuit,
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,
};
} }
async getNextIdentifier(sectionId: string) { async getNextIdentifier(sectionId: string) {
+213 -93
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js"; import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js"; import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
import { db } from "../src/db/client.js"; import { db } from "../src/db/client.js";
import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js"; import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js";
@@ -298,24 +299,24 @@ describe("circuit write service rules", () => {
}); });
it("moving a device row to another circuit preserves row and toggles reserve flags", async () => { it("moving a device row to another circuit preserves row and toggles reserve flags", async () => {
const updatedReserve: Array<{ id: string; isReserve: boolean }> = []; let transactionalMove:
const movedCalls: Array<{ rowId: string; targetCircuitId: string; sortOrder: number }> = []; | {
rows: Array<{ id: string; expectedCircuitId: string }>;
targetCircuitId?: string;
createTargetCircuit?: unknown;
}
| undefined;
const service = new CircuitWriteService({ const service = new CircuitWriteService({
deviceRowRepository: { deviceRowRepository: {
async findById() { async findById() {
return { id: "r1", circuitId: "c1" } as never; return { id: "r1", circuitId: "c1" } as never;
}, },
async countByCircuit(circuitId: string) { moveRowsTransactional(input: typeof transactionalMove) {
if (circuitId === "c2") { transactionalMove = input;
return 2; return {
} movedRowIds: input!.rows.map((row) => row.id),
if (circuitId === "c1") { targetCircuitId: input!.targetCircuitId!,
return 0; };
}
return 0;
},
async moveToCircuit(rowId: string, targetCircuitId: string, sortOrder: number) {
movedCalls.push({ rowId, targetCircuitId, sortOrder });
}, },
} as never, } as never,
circuitRepository: { circuitRepository: {
@@ -339,35 +340,39 @@ describe("circuit write service rules", () => {
isReserve: 1, isReserve: 1,
} as never; } as never;
}, },
async update(id: string, payload: { isReserve: boolean }) {
updatedReserve.push({ id, isReserve: payload.isReserve });
},
} as never, } as never,
}); });
await service.moveDeviceRow("r1", { targetCircuitId: "c2" }); await service.moveDeviceRow("r1", { targetCircuitId: "c2" });
assert.deepEqual(movedCalls, [{ rowId: "r1", targetCircuitId: "c2", sortOrder: 30 }]); assert.deepEqual(transactionalMove, {
assert.deepEqual(updatedReserve, [ rows: [{ id: "r1", expectedCircuitId: "c1" }],
{ id: "c1", isReserve: true }, targetCircuitId: "c2",
{ id: "c2", isReserve: false }, createTargetCircuit: undefined,
]); });
}); });
it("moving a device row to placeholder creates a new circuit in target section", async () => { it("moving a device row to placeholder creates a new circuit in target section", async () => {
let createdCircuitPayload: { sectionId: string; equipmentIdentifier: string; isReserve?: boolean } | null = null; let preparedTarget:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
const service = new CircuitWriteService({ const service = new CircuitWriteService({
deviceRowRepository: { deviceRowRepository: {
async findById() { async findById() {
return { id: "r1", circuitId: "c1" } as never; return { id: "r1", circuitId: "c1" } as never;
}, },
async countByCircuit(circuitId: string) { moveRowsTransactional(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) {
if (circuitId === "c-new") { preparedTarget = input.createTargetCircuit;
return 0; return {
} movedRowIds: input.rows.map((row) => row.id),
return 1; targetCircuitId: "c-new",
}, createdCircuitId: "c-new",
async moveToCircuit() { };
return;
}, },
} as never, } as never,
circuitRepository: { circuitRepository: {
@@ -382,28 +387,11 @@ describe("circuit write service rules", () => {
isReserve: 0, isReserve: 0,
} as never; } as never;
} }
if (circuitId === "c-new") {
return {
id: "c-new",
sectionId: "s2",
circuitListId: "l1",
equipmentIdentifier: "-2F8",
sortOrder: 50,
isReserve: 0,
} as never;
}
return null as never; return null as never;
}, },
async listBySection() { async listBySection() {
return [{ sortOrder: 40 }] as never[]; return [{ sortOrder: 40 }] as never[];
}, },
async create(payload: { sectionId: string; equipmentIdentifier: string; isReserve?: boolean }) {
createdCircuitPayload = payload;
return "c-new";
},
async update() {
return;
},
} as never, } as never,
circuitSectionRepository: { circuitSectionRepository: {
async findById() { async findById() {
@@ -418,14 +406,54 @@ describe("circuit write service rules", () => {
}); });
await service.moveDeviceRow("r1", { targetSectionId: "s2", createNewCircuit: true }); await service.moveDeviceRow("r1", { targetSectionId: "s2", createNewCircuit: true });
assert.equal(createdCircuitPayload?.sectionId, "s2"); assert.deepEqual(preparedTarget, {
assert.equal(createdCircuitPayload?.equipmentIdentifier, "-2F8"); circuitListId: "l1",
assert.equal(createdCircuitPayload?.isReserve, false); sectionId: "s2",
equipmentIdentifier: "-2F8",
displayName: "Neuer Stromkreis",
sortOrder: 50,
});
});
it("moving a device row to its current circuit remains a no-op", async () => {
let transactionalMoveCount = 0;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return { id: "r1", circuitId: "c1" } as never;
},
moveRowsTransactional() {
transactionalMoveCount += 1;
throw new Error("same-circuit move must not write");
},
} as never,
circuitRepository: {
async findById() {
return {
id: "c1",
sectionId: "s1",
circuitListId: "l1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 0,
} as never;
},
} as never,
});
const result = await service.moveDeviceRow("r1", { targetCircuitId: "c1" });
assert.equal(transactionalMoveCount, 0);
assert.equal(result?.id, "r1");
}); });
it("moving multiple device rows to a circuit preserves input order and toggles reserve", async () => { it("moving multiple device rows to a circuit preserves input order and toggles reserve", async () => {
const movedCalls: Array<{ rowId: string; targetCircuitId: string; sortOrder: number }> = []; let transactionalMove:
const reserveUpdates: Array<{ id: string; isReserve: boolean }> = []; | {
rows: Array<{ id: string; expectedCircuitId: string }>;
targetCircuitId?: string;
createTargetCircuit?: unknown;
}
| undefined;
const service = new CircuitWriteService({ const service = new CircuitWriteService({
deviceRowRepository: { deviceRowRepository: {
async findById(rowId: string) { async findById(rowId: string) {
@@ -434,14 +462,12 @@ describe("circuit write service rules", () => {
} }
return { id: "r2", circuitId: "c2" } as never; return { id: "r2", circuitId: "c2" } as never;
}, },
async countByCircuit(circuitId: string) { moveRowsTransactional(input: typeof transactionalMove) {
if (circuitId === "c3") { transactionalMove = input;
return 1; return {
} movedRowIds: input!.rows.map((row) => row.id),
return 0; targetCircuitId: input!.targetCircuitId!,
}, };
async moveToCircuit(rowId: string, targetCircuitId: string, sortOrder: number) {
movedCalls.push({ rowId, targetCircuitId, sortOrder });
}, },
} as never, } as never,
circuitRepository: { circuitRepository: {
@@ -454,39 +480,48 @@ describe("circuit write service rules", () => {
} }
return { id: "c3", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F3", sortOrder: 30, isReserve: 1 } as never; return { id: "c3", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F3", sortOrder: 30, isReserve: 1 } as never;
}, },
async update(id: string, payload: { isReserve: boolean }) {
reserveUpdates.push({ id, isReserve: payload.isReserve });
},
} as never, } as never,
}); });
await service.moveDeviceRowsBulk({ rowIds: ["r1", "r2"], targetCircuitId: "c3" }); const result = await service.moveDeviceRowsBulk({ rowIds: ["r1", "r2"], targetCircuitId: "c3" });
assert.deepEqual(movedCalls, [ assert.deepEqual(transactionalMove, {
{ rowId: "r1", targetCircuitId: "c3", sortOrder: 20 }, rows: [
{ rowId: "r2", targetCircuitId: "c3", sortOrder: 30 }, { id: "r1", expectedCircuitId: "c1" },
]); { id: "r2", expectedCircuitId: "c2" },
assert.deepEqual(reserveUpdates, [ ],
{ id: "c1", isReserve: true }, targetCircuitId: "c3",
{ id: "c2", isReserve: true }, createTargetCircuit: undefined,
{ id: "c3", isReserve: false }, });
]); assert.deepEqual(result, {
movedRowIds: ["r1", "r2"],
targetCircuitId: "c3",
});
}); });
it("moving multiple device rows to placeholder creates exactly one new circuit", async () => { it("moving multiple device rows to placeholder creates exactly one new circuit", async () => {
let createCount = 0; let transactionCount = 0;
let preparedTarget:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
const service = new CircuitWriteService({ const service = new CircuitWriteService({
deviceRowRepository: { deviceRowRepository: {
async findById(rowId: string) { async findById(rowId: string) {
return { id: rowId, circuitId: rowId === "r1" ? "c1" : "c2" } as never; return { id: rowId, circuitId: rowId === "r1" ? "c1" : "c2" } as never;
}, },
async countByCircuit(circuitId: string) { moveRowsTransactional(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) {
if (circuitId === "c-new") { transactionCount += 1;
return 0; preparedTarget = input.createTargetCircuit;
} return {
return 1; movedRowIds: input.rows.map((row) => row.id),
}, targetCircuitId: "c-new",
async moveToCircuit() { createdCircuitId: "c-new",
return; };
}, },
} as never, } as never,
circuitRepository: { circuitRepository: {
@@ -494,21 +529,11 @@ describe("circuit write service rules", () => {
if (circuitId === "c1" || circuitId === "c2") { if (circuitId === "c1" || circuitId === "c2") {
return { id: circuitId, sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never; return { id: circuitId, sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never;
} }
if (circuitId === "c-new") {
return { id: "c-new", sectionId: "s2", circuitListId: "l1", equipmentIdentifier: "-2F8", sortOrder: 40, isReserve: 0 } as never;
}
return null as never; return null as never;
}, },
async listBySection() { async listBySection() {
return [{ sortOrder: 30 }] as never[]; return [{ sortOrder: 30 }] as never[];
}, },
async create() {
createCount += 1;
return "c-new";
},
async update() {
return;
},
} as never, } as never,
circuitSectionRepository: { circuitSectionRepository: {
async findById() { async findById() {
@@ -527,7 +552,14 @@ describe("circuit write service rules", () => {
targetSectionId: "s2", targetSectionId: "s2",
createNewCircuit: true, createNewCircuit: true,
}); });
assert.equal(createCount, 1); assert.equal(transactionCount, 1);
assert.deepEqual(preparedTarget, {
circuitListId: "l1",
sectionId: "s2",
equipmentIdentifier: "-2F8",
displayName: "Neuer Stromkreis",
sortOrder: 40,
});
assert.equal(result.createdCircuitId, "c-new"); assert.equal(result.createdCircuitId, "c-new");
}); });
@@ -650,6 +682,94 @@ describe("circuit write service rules", () => {
} }
}); });
it("bulk device row move uses one synchronous transaction callback", () => {
const repository = new CircuitDeviceRowRepository();
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
let callbackReturnedPromise = false;
let selectCall = 0;
let rowUpdateCount = 0;
const selectedRows = [
[
{ id: "r1", circuitId: "c1" },
{ id: "r2", circuitId: "c2" },
],
[{ id: "c3", circuitListId: "l1" }],
[
{ id: "c1", circuitListId: "l1" },
{ id: "c2", circuitListId: "l1" },
],
[{ id: "existing-target-row", sortOrder: 10 }],
[],
[],
];
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
const fakeTx = {
select() {
const rows = selectedRows[selectCall++] ?? [];
const query = {
from() {
return query;
},
where() {
return query;
},
limit() {
return query;
},
all() {
return rows;
},
};
return query;
},
update() {
return {
set() {
return {
where() {
return {
run() {
rowUpdateCount += 1;
return { changes: 1 };
},
};
},
};
},
};
},
};
const callbackResult = cb(fakeTx);
callbackReturnedPromise = Boolean(
callbackResult && typeof (callbackResult as Promise<unknown>).then === "function"
);
if (callbackReturnedPromise) {
throw new Error("Transaction function cannot return a promise");
}
};
try {
const result = repository.moveRowsTransactional({
rows: [
{ id: "r1", expectedCircuitId: "c1" },
{ id: "r2", expectedCircuitId: "c2" },
],
targetCircuitId: "c3",
});
assert.equal(callbackReturnedPromise, false);
assert.equal(rowUpdateCount, 5);
assert.deepEqual(result, {
movedRowIds: ["r1", "r2"],
targetCircuitId: "c3",
createdCircuitId: undefined,
});
} finally {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
it("tracks local edits on linked device rows as overridden fields", async () => { it("tracks local edits on linked device rows as overridden fields", async () => {
let savedOverrides: string | undefined; let savedOverrides: string | undefined;
const current = { const current = {