Make device row writes atomic

This commit is contained in:
2026-07-23 17:28:42 +02:00
parent eb945a9622
commit 7d276d1139
4 changed files with 288 additions and 113 deletions
+2
View File
@@ -44,6 +44,8 @@ For insertion, a circuit-level cell creates a reserve circuit directly after the
For deletion, a circuit-level cell targets the complete circuit and a device-level cell targets only the device row. Deleting the last device requires an explicit choice between keeping the empty circuit as reserve, deleting the complete circuit, or cancelling. Delete commands are undoable and never renumber remaining circuits.
Creating or deleting a device row updates the row and the circuit reserve state in one SQLite transaction.
## Equipment Identifier Validation
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
@@ -26,6 +26,13 @@ export interface CircuitDeviceRowUpdateInput {
overriddenFields?: string;
}
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
circuitId: string;
linkedProjectDeviceId?: string;
legacyConsumerId?: string;
sortOrder: number;
}
export interface CircuitDeviceRowsBulkMoveInput {
rows: Array<{ id: string; expectedCircuitId: string }>;
targetCircuitId?: string;
@@ -60,6 +67,16 @@ function toUpdateValues(input: CircuitDeviceRowUpdateInput) {
};
}
function toCreateValues(id: string, input: CircuitDeviceRowCreateInput) {
return {
id,
circuitId: input.circuitId,
legacyConsumerId: input.legacyConsumerId ?? null,
sortOrder: input.sortOrder,
...toUpdateValues(input),
};
}
export class CircuitDeviceRowRepository {
async findById(rowId: string) {
const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1);
@@ -141,51 +158,43 @@ export class CircuitDeviceRowRepository {
.where(and(eq(circuitLists.projectId, projectId), inArray(circuitDeviceRows.id, rowIds)));
}
async create(input: {
circuitId: string;
linkedProjectDeviceId?: string;
legacyConsumerId?: string;
sortOrder: number;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}) {
async create(input: CircuitDeviceRowCreateInput) {
const id = crypto.randomUUID();
await db.insert(circuitDeviceRows).values({
id,
circuitId: input.circuitId,
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
legacyConsumerId: input.legacyConsumerId ?? null,
sortOrder: input.sortOrder,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType ?? null,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
level: input.level ?? null,
roomId: input.roomId ?? null,
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
roomNameSnapshot: input.roomNameSnapshot ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
overriddenFields: input.overriddenFields ?? null,
await db.insert(circuitDeviceRows).values(toCreateValues(id, input));
return id;
}
createInCircuitTransactional(
input: Omit<CircuitDeviceRowCreateInput, "sortOrder"> & { sortOrder?: number }
) {
const id = crypto.randomUUID();
db.transaction((tx) => {
const [circuit] = tx
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, input.circuitId))
.limit(1)
.all();
if (!circuit) {
throw new Error("Der Stromkreis ist ungültig.");
}
const existingRows = tx
.select({ sortOrder: circuitDeviceRows.sortOrder })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, input.circuitId))
.all();
const lastSortOrder = existingRows.reduce(
(highest, row) => Math.max(highest, row.sortOrder),
0
);
const sortOrder = input.sortOrder ?? lastSortOrder + 10;
tx
.insert(circuitDeviceRows)
.values(toCreateValues(id, { ...input, sortOrder }))
.run();
tx.update(circuits).set({ isReserve: 0 }).where(eq(circuits.id, input.circuitId)).run();
});
return id;
}
@@ -262,6 +271,44 @@ export class CircuitDeviceRowRepository {
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
}
deleteFromCircuitTransactional(rowId: string, expectedCircuitId: string) {
db.transaction((tx) => {
const [row] = tx
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, rowId))
.limit(1)
.all();
if (!row || row.circuitId !== expectedCircuitId) {
throw new Error("Die Gerätezeile wurde vor dem Löschen verändert.");
}
const result = tx
.delete(circuitDeviceRows)
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.circuitId, expectedCircuitId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("Die Gerätezeile konnte nicht gelöscht werden.");
}
const remainingRows = tx
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, expectedCircuitId))
.all();
tx
.update(circuits)
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
.where(eq(circuits.id, expectedCircuitId))
.run();
});
}
async moveToCircuit(rowId: string, targetCircuitId: string, sortOrder: number) {
await db
.update(circuitDeviceRows)
+3 -50
View File
@@ -171,11 +171,10 @@ export class CircuitWriteService {
}
await this.assertValidLinkedProjectDevice(circuitId, input.linkedProjectDeviceId);
const existingRows = await this.deviceRowRepository.countByCircuit(circuitId);
const rowId = await this.deviceRowRepository.create({
const rowId = this.deviceRowRepository.createInCircuitTransactional({
circuitId,
linkedProjectDeviceId: input.linkedProjectDeviceId,
sortOrder: input.sortOrder ?? (existingRows + 1) * 10,
sortOrder: input.sortOrder,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType,
@@ -194,29 +193,6 @@ export class CircuitWriteService {
overriddenFields: input.overriddenFields,
});
// Reserve circuits become active as soon as at least one device row exists.
if (Boolean(circuit.isReserve)) {
await this.circuitRepository.update(circuit.id, {
sectionId: circuit.sectionId,
equipmentIdentifier: circuit.equipmentIdentifier,
displayName: circuit.displayName ?? undefined,
sortOrder: circuit.sortOrder,
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: false,
remark: circuit.remark ?? undefined,
});
}
return this.deviceRowRepository.findById(rowId);
}
@@ -272,30 +248,7 @@ export class CircuitWriteService {
if (!circuit) {
throw new Error("Invalid circuit id.");
}
await this.deviceRowRepository.delete(rowId);
const remaining = await this.deviceRowRepository.countByCircuit(current.circuitId);
// When last row is removed, keep circuit and mark it reserve instead of deleting it.
if (remaining === 0) {
await this.circuitRepository.update(circuit.id, {
sectionId: circuit.sectionId,
equipmentIdentifier: circuit.equipmentIdentifier,
displayName: circuit.displayName ?? undefined,
sortOrder: circuit.sortOrder,
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: true,
remark: circuit.remark ?? undefined,
});
}
this.deviceRowRepository.deleteFromCircuitTransactional(rowId, circuit.id);
}
async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) {
+192 -19
View File
@@ -117,17 +117,14 @@ describe("circuit write service rules", () => {
});
it("deleting last device row keeps circuit and sets reserve", async () => {
let reserveFlag = false;
let transactionalDelete: { rowId: string; circuitId: string } | undefined;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return { id: "r1", circuitId: "c1" } as never;
},
async delete() {
return;
},
async countByCircuit() {
return 0;
deleteFromCircuitTransactional(rowId: string, circuitId: string) {
transactionalDelete = { rowId, circuitId };
},
} as never,
circuitRepository: {
@@ -141,18 +138,22 @@ describe("circuit write service rules", () => {
isReserve: 0,
} as never;
},
async update(_id: string, payload: { isReserve: boolean }) {
reserveFlag = payload.isReserve;
},
} as never,
});
await service.deleteDeviceRow("r1");
assert.equal(reserveFlag, true);
assert.deepEqual(transactionalDelete, { rowId: "r1", circuitId: "c1" });
});
it("creating device row in reserve circuit clears reserve status", async () => {
let reserveFlag = true;
let transactionalCreate:
| {
circuitId: string;
sortOrder?: number;
name: string;
displayName: string;
}
| undefined;
const service = new CircuitWriteService({
circuitRepository: {
async findById() {
@@ -165,15 +166,10 @@ describe("circuit write service rules", () => {
isReserve: 1,
} as never;
},
async update(_id: string, payload: { isReserve: boolean }) {
reserveFlag = payload.isReserve;
},
} as never,
deviceRowRepository: {
async countByCircuit() {
return 0;
},
async create() {
createInCircuitTransactional(input: typeof transactionalCreate) {
transactionalCreate = input;
return "row1";
},
async findById() {
@@ -196,7 +192,27 @@ describe("circuit write service rules", () => {
powerPerUnit: 1,
simultaneityFactor: 1,
});
assert.equal(reserveFlag, false);
assert.deepEqual(transactionalCreate, {
circuitId: "c1",
linkedProjectDeviceId: undefined,
sortOrder: undefined,
name: "Load",
displayName: "Load",
phaseType: undefined,
connectionKind: undefined,
costGroup: undefined,
category: undefined,
level: undefined,
roomId: undefined,
roomNumberSnapshot: undefined,
roomNameSnapshot: undefined,
quantity: 1,
powerPerUnit: 1,
simultaneityFactor: 1,
cosPhi: undefined,
remark: undefined,
overriddenFields: undefined,
});
});
it("renumber uses safe bulk identifier update for swapped identifiers", async () => {
@@ -770,6 +786,163 @@ describe("circuit write service rules", () => {
}
});
it("device row creation and reserve activation share one synchronous transaction", () => {
const repository = new CircuitDeviceRowRepository();
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
let callbackReturnedPromise = false;
let insertedValues: { id?: string; circuitId?: string; sortOrder?: number } | undefined;
let reserveValue: number | undefined;
let selectCall = 0;
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
const fakeTx = {
select() {
const rows = selectCall++ === 0 ? [{ id: "c1" }] : [{ sortOrder: 20 }];
const query = {
from() {
return query;
},
where() {
return query;
},
limit() {
return query;
},
all() {
return rows;
},
};
return query;
},
insert() {
return {
values(values: typeof insertedValues) {
insertedValues = values;
return {
run() {
return { changes: 1 };
},
};
},
};
},
update() {
return {
set(values: { isReserve: number }) {
reserveValue = values.isReserve;
return {
where() {
return {
run() {
return { changes: 1 };
},
};
},
};
},
};
},
};
const callbackResult = cb(fakeTx);
callbackReturnedPromise = Boolean(
callbackResult && typeof (callbackResult as Promise<unknown>).then === "function"
);
};
try {
const rowId = repository.createInCircuitTransactional({
circuitId: "c1",
name: "Load",
displayName: "Load",
quantity: 1,
powerPerUnit: 1,
simultaneityFactor: 1,
});
assert.equal(callbackReturnedPromise, false);
assert.equal(insertedValues?.id, rowId);
assert.equal(insertedValues?.circuitId, "c1");
assert.equal(insertedValues?.sortOrder, 30);
assert.equal(reserveValue, 0);
} finally {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
it("device row deletion and reserve update share one synchronous transaction", () => {
const repository = new CircuitDeviceRowRepository();
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
let callbackReturnedPromise = false;
let deleteCount = 0;
let reserveValue: number | undefined;
let selectCall = 0;
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
const fakeTx = {
select() {
const rows = selectCall++ === 0 ? [{ id: "r1", circuitId: "c1" }] : [];
const query = {
from() {
return query;
},
where() {
return query;
},
limit() {
return query;
},
all() {
return rows;
},
};
return query;
},
delete() {
return {
where() {
return {
run() {
deleteCount += 1;
return { changes: 1 };
},
};
},
};
},
update() {
return {
set(values: { isReserve: number }) {
reserveValue = values.isReserve;
return {
where() {
return {
run() {
return { changes: 1 };
},
};
},
};
},
};
},
};
const callbackResult = cb(fakeTx);
callbackReturnedPromise = Boolean(
callbackResult && typeof (callbackResult as Promise<unknown>).then === "function"
);
};
try {
repository.deleteFromCircuitTransactional("r1", "c1");
assert.equal(callbackReturnedPromise, false);
assert.equal(deleteCount, 1);
assert.equal(reserveValue, 1);
} finally {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
it("tracks local edits on linked device rows as overridden fields", async () => {
let savedOverrides: string | undefined;
const current = {