Prevent stale cell update overwrites

This commit is contained in:
2026-07-23 17:43:11 +02:00
parent 0800f984bb
commit 1c57df46fa
5 changed files with 128 additions and 47 deletions
+2
View File
@@ -11,6 +11,8 @@ Inline cells are static text by default. A cell enters edit mode by:
`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell.
Committed cell edits are persisted as partial database updates. Fields that were not part of the edit are not written back from an older read and therefore cannot overwrite a concurrent change to another field.
## `selectedCell` vs `editingCell`
- `selectedCell` tracks spreadsheet navigation focus.
@@ -30,6 +30,10 @@ export interface CircuitDeviceRowUpdateInput {
overriddenFields?: string;
}
export type CircuitDeviceRowPatchInput = Partial<CircuitDeviceRowUpdateInput> & {
sortOrder?: number;
};
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
circuitId: string;
linkedProjectDeviceId?: string;
@@ -78,6 +82,39 @@ function toUpdateValues(input: CircuitDeviceRowUpdateInput) {
};
}
function toPatchValues(input: CircuitDeviceRowPatchInput) {
const values: Partial<typeof circuitDeviceRows.$inferInsert> = {};
const has = (field: keyof CircuitDeviceRowPatchInput) =>
Object.prototype.hasOwnProperty.call(input, field);
if (has("linkedProjectDeviceId")) {
values.linkedProjectDeviceId = input.linkedProjectDeviceId ?? null;
}
if (input.name !== undefined) values.name = input.name;
if (input.displayName !== undefined) values.displayName = input.displayName;
if (has("phaseType")) values.phaseType = input.phaseType ?? null;
if (has("connectionKind")) values.connectionKind = input.connectionKind ?? null;
if (has("costGroup")) values.costGroup = input.costGroup ?? null;
if (has("category")) values.category = input.category ?? null;
if (has("level")) values.level = input.level ?? null;
if (has("roomId")) values.roomId = input.roomId ?? null;
if (has("roomNumberSnapshot")) {
values.roomNumberSnapshot = input.roomNumberSnapshot ?? null;
}
if (has("roomNameSnapshot")) values.roomNameSnapshot = input.roomNameSnapshot ?? null;
if (input.quantity !== undefined) values.quantity = input.quantity;
if (input.powerPerUnit !== undefined) values.powerPerUnit = input.powerPerUnit;
if (input.simultaneityFactor !== undefined) {
values.simultaneityFactor = input.simultaneityFactor;
}
if (has("cosPhi")) values.cosPhi = input.cosPhi ?? null;
if (has("remark")) values.remark = input.remark ?? null;
if (has("overriddenFields")) values.overriddenFields = input.overriddenFields ?? null;
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
return values;
}
function toCreateValues(id: string, input: CircuitDeviceRowCreateInput) {
return {
id,
@@ -256,6 +293,14 @@ export class CircuitDeviceRowRepository {
.where(eq(circuitDeviceRows.id, rowId));
}
async updateFields(rowId: string, input: CircuitDeviceRowPatchInput) {
const values = toPatchValues(input);
if (Object.keys(values).length === 0) {
return;
}
await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId));
}
updateLinkedRowsTransactional(
projectDeviceId: string,
changes: Array<{ rowId: string; input: CircuitDeviceRowUpdateInput }>
+64
View File
@@ -24,6 +24,26 @@ export interface CircuitCreatePersistenceInput {
isReserve?: boolean;
}
export interface CircuitPatchPersistenceInput {
sectionId?: string;
equipmentIdentifier?: string;
displayName?: string;
sortOrder?: number;
protectionType?: string;
protectionRatedCurrent?: number;
protectionCharacteristic?: string;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
voltage?: number;
controlRequirement?: string;
remark?: string;
rcdAssignment?: string;
terminalDesignation?: string;
status?: string;
isReserve?: boolean;
}
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
return {
id,
@@ -48,6 +68,42 @@ export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenc
};
}
function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
const values: Partial<typeof circuits.$inferInsert> = {};
const has = (field: keyof CircuitPatchPersistenceInput) =>
Object.prototype.hasOwnProperty.call(input, field);
if (input.sectionId !== undefined) values.sectionId = input.sectionId;
if (input.equipmentIdentifier !== undefined) {
values.equipmentIdentifier = input.equipmentIdentifier;
}
if (has("displayName")) values.displayName = input.displayName ?? null;
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
if (has("protectionType")) values.protectionType = input.protectionType ?? null;
if (has("protectionRatedCurrent")) {
values.protectionRatedCurrent = input.protectionRatedCurrent ?? null;
}
if (has("protectionCharacteristic")) {
values.protectionCharacteristic = input.protectionCharacteristic ?? null;
}
if (has("cableType")) values.cableType = input.cableType ?? null;
if (has("cableCrossSection")) values.cableCrossSection = input.cableCrossSection ?? null;
if (has("cableLength")) values.cableLength = input.cableLength ?? null;
if (has("rcdAssignment")) values.rcdAssignment = input.rcdAssignment ?? null;
if (has("terminalDesignation")) {
values.terminalDesignation = input.terminalDesignation ?? null;
}
if (has("voltage")) values.voltage = input.voltage ?? null;
if (has("controlRequirement")) {
values.controlRequirement = input.controlRequirement ?? null;
}
if (has("status")) values.status = input.status ?? null;
if (input.isReserve !== undefined) values.isReserve = input.isReserve ? 1 : 0;
if (has("remark")) values.remark = input.remark ?? null;
return values;
}
export class CircuitRepository {
async findById(circuitId: string) {
const [row] = await db.select().from(circuits).where(eq(circuits.id, circuitId)).limit(1);
@@ -114,6 +170,14 @@ export class CircuitRepository {
.where(eq(circuits.id, circuitId));
}
async updateFields(circuitId: string, input: CircuitPatchPersistenceInput) {
const values = toCircuitPatchValues(input);
if (Object.keys(values).length === 0) {
return;
}
await db.update(circuits).set(values).where(eq(circuits.id, circuitId));
}
async delete(circuitId: string) {
await db.delete(circuits).where(eq(circuits.id, circuitId));
}
+5 -40
View File
@@ -182,30 +182,10 @@ export class CircuitWriteService {
const sectionId = input.sectionId ?? current.sectionId;
const equipmentIdentifier = input.equipmentIdentifier ?? current.equipmentIdentifier;
const sortOrder = input.sortOrder ?? current.sortOrder;
await this.assertSectionInList(sectionId, current.circuitListId);
await this.assertUniqueEquipmentIdentifier(current.circuitListId, equipmentIdentifier, circuitId);
await this.circuitRepository.update(circuitId, {
sectionId,
equipmentIdentifier,
displayName: input.displayName ?? current.displayName ?? undefined,
sortOrder,
protectionType: input.protectionType ?? current.protectionType ?? undefined,
protectionRatedCurrent: input.protectionRatedCurrent ?? current.protectionRatedCurrent ?? undefined,
protectionCharacteristic:
input.protectionCharacteristic ?? current.protectionCharacteristic ?? undefined,
cableType: input.cableType ?? current.cableType ?? undefined,
cableCrossSection: input.cableCrossSection ?? current.cableCrossSection ?? undefined,
cableLength: input.cableLength ?? current.cableLength ?? undefined,
rcdAssignment: input.rcdAssignment ?? current.rcdAssignment ?? undefined,
terminalDesignation: input.terminalDesignation ?? current.terminalDesignation ?? undefined,
voltage: input.voltage ?? current.voltage ?? undefined,
controlRequirement: input.controlRequirement ?? current.controlRequirement ?? undefined,
status: input.status ?? current.status ?? undefined,
isReserve: input.isReserve ?? Boolean(current.isReserve),
remark: input.remark ?? current.remark ?? undefined,
});
await this.circuitRepository.updateFields(circuitId, input);
return this.circuitRepository.findById(circuitId);
}
@@ -255,7 +235,7 @@ export class CircuitWriteService {
throw new Error("Invalid device row id.");
}
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
let overriddenFields = input.overriddenFields ?? current.overriddenFields ?? undefined;
let overriddenFields = input.overriddenFields;
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
const inputValues = input as Record<string, unknown>;
@@ -270,24 +250,9 @@ export class CircuitWriteService {
}
overriddenFields = serializeOverriddenFields(overrides);
}
await this.deviceRowRepository.update(rowId, {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? current.linkedProjectDeviceId ?? undefined,
name: input.name ?? current.name,
displayName: input.displayName ?? current.displayName,
phaseType: input.phaseType ?? current.phaseType ?? undefined,
connectionKind: input.connectionKind ?? current.connectionKind ?? undefined,
costGroup: input.costGroup ?? current.costGroup ?? undefined,
category: input.category ?? current.category ?? undefined,
level: input.level ?? current.level ?? undefined,
roomId: input.roomId ?? current.roomId ?? undefined,
roomNumberSnapshot: input.roomNumberSnapshot ?? current.roomNumberSnapshot ?? undefined,
roomNameSnapshot: input.roomNameSnapshot ?? current.roomNameSnapshot ?? undefined,
quantity: input.quantity ?? current.quantity,
powerPerUnit: input.powerPerUnit ?? current.powerPerUnit,
simultaneityFactor: input.simultaneityFactor ?? current.simultaneityFactor,
cosPhi: input.cosPhi ?? current.cosPhi ?? undefined,
remark: input.remark ?? current.remark ?? undefined,
overriddenFields,
await this.deviceRowRepository.updateFields(rowId, {
...input,
...(overriddenFields !== undefined ? { overriddenFields } : {}),
});
return this.deviceRowRepository.findById(rowId);
}
+12 -7
View File
@@ -44,7 +44,7 @@ describe("circuit write service rules", () => {
async existsByEquipmentIdentifier() {
return false;
},
async update(_id: string, payload: Record<string, unknown>) {
async updateFields(_id: string, payload: Record<string, unknown>) {
updatePayload = payload;
},
} as never,
@@ -52,8 +52,10 @@ describe("circuit write service rules", () => {
await service.updateCircuit("c1", { voltage: 400, controlRequirement: "DALI" });
assert.equal(updatePayload.voltage, 400);
assert.equal(updatePayload.controlRequirement, "DALI");
assert.deepEqual(updatePayload, {
voltage: 400,
controlRequirement: "DALI",
});
});
it("rejects duplicate equipment identifiers in same circuit list", async () => {
@@ -1147,7 +1149,7 @@ describe("circuit write service rules", () => {
});
it("tracks local edits on linked device rows as overridden fields", async () => {
let savedOverrides: string | undefined;
let updatePayload: Record<string, unknown> = {};
const current = {
id: "r1",
circuitId: "c1",
@@ -1165,14 +1167,17 @@ describe("circuit write service rules", () => {
async findById() {
return current as never;
},
async update(_rowId: string, input: { overriddenFields?: string }) {
savedOverrides = input.overriddenFields;
async updateFields(_rowId: string, input: Record<string, unknown>) {
updatePayload = input;
},
} as never,
});
await service.updateDeviceRow("r1", { quantity: 2 });
assert.deepEqual(JSON.parse(savedOverrides ?? "[]"), ["quantity"]);
assert.deepEqual(updatePayload, {
quantity: 2,
overriddenFields: "[\"quantity\"]",
});
});
});