From 1c57df46fadee7143efc0bddc9e80e92f4456de1 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 23 Jul 2026 17:43:11 +0200 Subject: [PATCH] Prevent stale cell update overwrites --- docs/circuit-list-editor-interactions.md | 2 + .../circuit-device-row.repository.ts | 45 +++++++++++++ src/db/repositories/circuit.repository.ts | 64 +++++++++++++++++++ src/domain/services/circuit-write.service.ts | 45 ++----------- tests/circuit-write.rules.test.ts | 19 ++++-- 5 files changed, 128 insertions(+), 47 deletions(-) diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index cc8001e..a58391f 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -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. diff --git a/src/db/repositories/circuit-device-row.repository.ts b/src/db/repositories/circuit-device-row.repository.ts index 034fe5a..f66de15 100644 --- a/src/db/repositories/circuit-device-row.repository.ts +++ b/src/db/repositories/circuit-device-row.repository.ts @@ -30,6 +30,10 @@ export interface CircuitDeviceRowUpdateInput { overriddenFields?: string; } +export type CircuitDeviceRowPatchInput = Partial & { + 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 = {}; + 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 }> diff --git a/src/db/repositories/circuit.repository.ts b/src/db/repositories/circuit.repository.ts index 9382c39..8270704 100644 --- a/src/db/repositories/circuit.repository.ts +++ b/src/db/repositories/circuit.repository.ts @@ -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 = {}; + 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)); } diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index 789e815..a40d782 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -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; @@ -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); } diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts index 5ee18b6..bab9834 100644 --- a/tests/circuit-write.rules.test.ts +++ b/tests/circuit-write.rules.test.ts @@ -44,7 +44,7 @@ describe("circuit write service rules", () => { async existsByEquipmentIdentifier() { return false; }, - async update(_id: string, payload: Record) { + async updateFields(_id: string, payload: Record) { 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 = {}; 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) { + updatePayload = input; }, } as never, }); await service.updateDeviceRow("r1", { quantity: 2 }); - assert.deepEqual(JSON.parse(savedOverrides ?? "[]"), ["quantity"]); + assert.deepEqual(updatePayload, { + quantity: 2, + overriddenFields: "[\"quantity\"]", + }); }); });