From a6837d7242987151d11ce621f3083ee0dfb6512c Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 23 Jul 2026 18:46:50 +0200 Subject: [PATCH] Make initial circuit creation atomic --- docs/spec/07-implementation-phases-todo.md | 3 +- ...rcuit-device-row-transaction.repository.ts | 38 ++++++ .../circuit-device-row.repository.ts | 48 -------- src/db/repositories/circuit.persistence.ts | 44 +++++++ src/db/repositories/circuit.repository.ts | 50 +------- .../circuit-device-row-transaction.store.ts | 28 +++++ src/domain/services/circuit-write.service.ts | 3 +- ...-device-row-transaction.repository.test.ts | 109 ++++++++++++++++++ tests/circuit-write.rules.test.ts | 80 +------------ 9 files changed, 234 insertions(+), 169 deletions(-) create mode 100644 src/db/repositories/circuit.persistence.ts diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index dcae871..f7ff99a 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -329,8 +329,9 @@ Implemented foundation: - the distribution-board repository receives its database dependency explicitly - distribution-board setup has real in-memory SQLite commit and rollback coverage using production migrations - circuit-device-row creation/deletion and reserve-state changes use an explicitly injected transaction store +- a new circuit and all of its initial device rows use the same transaction store - circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks -- persistence value mapping is separated from the general circuit-device-row repository +- circuit and device-row persistence value mapping is separated from the general repositories ## Phase 12: Project Revisions and Persistent Undo / Redo diff --git a/src/db/repositories/circuit-device-row-transaction.repository.ts b/src/db/repositories/circuit-device-row-transaction.repository.ts index 52f27e5..220b8c7 100644 --- a/src/db/repositories/circuit-device-row-transaction.repository.ts +++ b/src/db/repositories/circuit-device-row-transaction.repository.ts @@ -3,10 +3,12 @@ import { and, eq } from "drizzle-orm"; import type { CircuitDeviceRowTransactionStore, CreateCircuitDeviceRowTransactionInput, + CreateCircuitWithDeviceRowsTransactionInput, } from "../../domain/ports/circuit-device-row-transaction.store.js"; import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuits } from "../schema/circuits.js"; +import { toCircuitCreateValues } from "./circuit.persistence.js"; import { toCircuitDeviceRowCreateValues, } from "./circuit-device-row.persistence.js"; @@ -53,6 +55,42 @@ export class CircuitDeviceRowTransactionRepository return id; } + createCircuitWithDeviceRows(input: CreateCircuitWithDeviceRowsTransactionInput) { + if (input.deviceRows.length === 0) { + throw new Error("Mindestens eine Gerätezeile ist erforderlich."); + } + + const circuitId = crypto.randomUUID(); + const rowIds = input.deviceRows.map(() => crypto.randomUUID()); + this.database.transaction((tx) => { + tx + .insert(circuits) + .values( + toCircuitCreateValues(circuitId, { + ...input.circuit, + isReserve: false, + }) + ) + .run(); + + for (let index = 0; index < input.deviceRows.length; index += 1) { + const row = input.deviceRows[index]; + tx + .insert(circuitDeviceRows) + .values( + toCircuitDeviceRowCreateValues(rowIds[index], { + ...row, + circuitId, + sortOrder: row.sortOrder ?? (index + 1) * 10, + }) + ) + .run(); + } + }); + + return { circuitId, rowIds }; + } + deleteFromCircuit(rowId: string, expectedCircuitId: string) { this.database.transaction((tx) => { const [row] = tx diff --git a/src/db/repositories/circuit-device-row.repository.ts b/src/db/repositories/circuit-device-row.repository.ts index 669df08..a75408d 100644 --- a/src/db/repositories/circuit-device-row.repository.ts +++ b/src/db/repositories/circuit-device-row.repository.ts @@ -13,11 +13,6 @@ import { type CircuitDeviceRowPatchInput, type CircuitDeviceRowUpdateInput, } from "./circuit-device-row.persistence.js"; -import { - toCircuitCreateValues, - type CircuitCreatePersistenceInput, -} from "./circuit.repository.js"; - export type { CircuitDeviceRowCreateInput, CircuitDeviceRowPatchInput, @@ -37,13 +32,6 @@ export interface CircuitDeviceRowsBulkMoveInput { }; } -export interface CircuitWithDeviceRowsCreateInput { - circuit: CircuitCreatePersistenceInput; - deviceRows: Array< - Omit & { sortOrder?: number } - >; -} - export class CircuitDeviceRowRepository { async findById(rowId: string) { const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1); @@ -131,42 +119,6 @@ export class CircuitDeviceRowRepository { return id; } - createCircuitWithDeviceRowsTransactional(input: CircuitWithDeviceRowsCreateInput) { - if (input.deviceRows.length === 0) { - throw new Error("Mindestens eine Gerätezeile ist erforderlich."); - } - - const circuitId = crypto.randomUUID(); - const rowIds = input.deviceRows.map(() => crypto.randomUUID()); - db.transaction((tx) => { - tx - .insert(circuits) - .values( - toCircuitCreateValues(circuitId, { - ...input.circuit, - isReserve: false, - }) - ) - .run(); - - for (let index = 0; index < input.deviceRows.length; index += 1) { - const row = input.deviceRows[index]; - tx - .insert(circuitDeviceRows) - .values( - toCircuitDeviceRowCreateValues(rowIds[index], { - ...row, - circuitId, - sortOrder: row.sortOrder ?? (index + 1) * 10, - }) - ) - .run(); - } - }); - - return { circuitId, rowIds }; - } - async update( rowId: string, input: CircuitDeviceRowUpdateInput diff --git a/src/db/repositories/circuit.persistence.ts b/src/db/repositories/circuit.persistence.ts new file mode 100644 index 0000000..d1b4bb6 --- /dev/null +++ b/src/db/repositories/circuit.persistence.ts @@ -0,0 +1,44 @@ +export interface CircuitCreatePersistenceInput { + circuitListId: string; + 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, + circuitListId: input.circuitListId, + sectionId: input.sectionId, + equipmentIdentifier: input.equipmentIdentifier, + displayName: input.displayName ?? null, + sortOrder: input.sortOrder, + protectionType: input.protectionType ?? null, + protectionRatedCurrent: input.protectionRatedCurrent ?? null, + protectionCharacteristic: input.protectionCharacteristic ?? null, + cableType: input.cableType ?? null, + cableCrossSection: input.cableCrossSection ?? null, + cableLength: input.cableLength ?? null, + rcdAssignment: input.rcdAssignment ?? null, + terminalDesignation: input.terminalDesignation ?? null, + voltage: input.voltage ?? null, + controlRequirement: input.controlRequirement ?? null, + status: input.status ?? null, + isReserve: input.isReserve ? 1 : 0, + remark: input.remark ?? null, + }; +} diff --git a/src/db/repositories/circuit.repository.ts b/src/db/repositories/circuit.repository.ts index 8270704..69d7da6 100644 --- a/src/db/repositories/circuit.repository.ts +++ b/src/db/repositories/circuit.repository.ts @@ -2,27 +2,13 @@ import crypto from "node:crypto"; import { and, asc, eq, inArray, ne } from "drizzle-orm"; import { db } from "../client.js"; import { circuits } from "../schema/circuits.js"; +import { + toCircuitCreateValues, + type CircuitCreatePersistenceInput, +} from "./circuit.persistence.js"; -export interface CircuitCreatePersistenceInput { - circuitListId: string; - 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 type { CircuitCreatePersistenceInput } from "./circuit.persistence.js"; +export { toCircuitCreateValues } from "./circuit.persistence.js"; export interface CircuitPatchPersistenceInput { sectionId?: string; @@ -44,30 +30,6 @@ export interface CircuitPatchPersistenceInput { isReserve?: boolean; } -export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) { - return { - id, - circuitListId: input.circuitListId, - sectionId: input.sectionId, - equipmentIdentifier: input.equipmentIdentifier, - displayName: input.displayName ?? null, - sortOrder: input.sortOrder, - protectionType: input.protectionType ?? null, - protectionRatedCurrent: input.protectionRatedCurrent ?? null, - protectionCharacteristic: input.protectionCharacteristic ?? null, - cableType: input.cableType ?? null, - cableCrossSection: input.cableCrossSection ?? null, - cableLength: input.cableLength ?? null, - rcdAssignment: input.rcdAssignment ?? null, - terminalDesignation: input.terminalDesignation ?? null, - voltage: input.voltage ?? null, - controlRequirement: input.controlRequirement ?? null, - status: input.status ?? null, - isReserve: input.isReserve ? 1 : 0, - remark: input.remark ?? null, - }; -} - function toCircuitPatchValues(input: CircuitPatchPersistenceInput) { const values: Partial = {}; const has = (field: keyof CircuitPatchPersistenceInput) => diff --git a/src/domain/ports/circuit-device-row-transaction.store.ts b/src/domain/ports/circuit-device-row-transaction.store.ts index 504d0b9..0cca4b8 100644 --- a/src/domain/ports/circuit-device-row-transaction.store.ts +++ b/src/domain/ports/circuit-device-row-transaction.store.ts @@ -20,7 +20,35 @@ export interface CreateCircuitDeviceRowTransactionInput { overriddenFields?: string; } +export interface CreateCircuitTransactionInput { + circuitListId: string; + 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; +} + +export interface CreateCircuitWithDeviceRowsTransactionInput { + circuit: CreateCircuitTransactionInput; + deviceRows: Array>; +} + export interface CircuitDeviceRowTransactionStore { createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string; + createCircuitWithDeviceRows( + input: CreateCircuitWithDeviceRowsTransactionInput + ): { circuitId: string; rowIds: string[] }; deleteFromCircuit(rowId: string, expectedCircuitId: string): void; } diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index 3f8372e..fed6166 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -164,11 +164,10 @@ export class CircuitWriteService { ); } - const created = this.deviceRowRepository.createCircuitWithDeviceRowsTransactional({ + const created = this.getDeviceRowTransactionStore().createCircuitWithDeviceRows({ circuit: { circuitListId, ...input.circuit, - isReserve: false, }, deviceRows: input.deviceRows, }); diff --git a/tests/circuit-device-row-transaction.repository.test.ts b/tests/circuit-device-row-transaction.repository.test.ts index 247e6b4..a4864e5 100644 --- a/tests/circuit-device-row-transaction.repository.test.ts +++ b/tests/circuit-device-row-transaction.repository.test.ts @@ -134,6 +134,115 @@ describe("circuit device-row transaction repository", () => { } }); + it("commits a new circuit and all initial device rows together", () => { + const context = createTestDatabase(); + try { + const [seedCircuit] = context.db.select().from(circuits).limit(1).all(); + const repository = new CircuitDeviceRowTransactionRepository(context.db); + const created = repository.createCircuitWithDeviceRows({ + circuit: { + circuitListId: seedCircuit.circuitListId, + sectionId: seedCircuit.sectionId, + equipmentIdentifier: "-1F2", + displayName: "Beleuchtung", + sortOrder: 20, + }, + deviceRows: [ + { + name: "Leuchte", + displayName: "Leuchte 1", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + sortOrder: 15, + }, + { + name: "Leuchte", + displayName: "Leuchte 2", + quantity: 2, + powerPerUnit: 0.1, + simultaneityFactor: 0.8, + }, + ], + }); + + const [createdCircuit] = context.db + .select() + .from(circuits) + .where(eq(circuits.id, created.circuitId)) + .all(); + const createdRows = context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, created.circuitId)) + .all() + .sort((left, right) => left.sortOrder - right.sortOrder); + + assert.equal(createdCircuit.equipmentIdentifier, "-1F2"); + assert.equal(createdCircuit.isReserve, 0); + assert.deepEqual( + createdRows.map((row) => [row.id, row.displayName, row.sortOrder]), + [ + [created.rowIds[0], "Leuchte 1", 15], + [created.rowIds[1], "Leuchte 2", 20], + ] + ); + } finally { + context.close(); + } + }); + + it("rolls back the circuit and earlier rows when an initial row insert fails", () => { + const context = createTestDatabase(); + try { + const [seedCircuit] = context.db.select().from(circuits).limit(1).all(); + context.sqlite.exec(` + CREATE TRIGGER fail_initial_device_row + BEFORE INSERT ON circuit_device_rows + WHEN NEW.name = 'Fail' + BEGIN + SELECT RAISE(ABORT, 'forced initial row failure'); + END; + `); + const repository = new CircuitDeviceRowTransactionRepository(context.db); + + assert.throws( + () => + repository.createCircuitWithDeviceRows({ + circuit: { + circuitListId: seedCircuit.circuitListId, + sectionId: seedCircuit.sectionId, + equipmentIdentifier: "-1F2", + displayName: "Rollback", + sortOrder: 20, + }, + deviceRows: [ + { + name: "First", + displayName: "Erste Zeile", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }, + { + name: "Fail", + displayName: "Fehlerhafte Zeile", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }, + ], + }), + /forced initial row failure/ + ); + + assert.equal(context.db.select().from(circuits).all().length, 1); + assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0); + } finally { + context.close(); + } + }); + it("commits the last-row deletion and activates the circuit reserve status together", () => { const context = createTestDatabase(); try { diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts index 1e00fe2..d09557f 100644 --- a/tests/circuit-write.rules.test.ts +++ b/tests/circuit-write.rules.test.ts @@ -251,14 +251,16 @@ describe("circuit write service rules", () => { }, } as never, deviceRowRepository: { - createCircuitWithDeviceRowsTransactional(input: typeof transactionalCreate) { - transactionalCreate = input; - return { circuitId: "c-new", rowIds: ["r1", "r2"] }; - }, async findById(rowId: string) { return { id: rowId, circuitId: "c-new" } as never; }, } as never, + deviceRowTransactionStore: { + createCircuitWithDeviceRows(input: typeof transactionalCreate) { + transactionalCreate = input; + return { circuitId: "c-new", rowIds: ["r1", "r2"] }; + }, + } as never, }); const created = await service.createCircuitWithDeviceRows("p1", "l1", { @@ -925,76 +927,6 @@ describe("circuit write service rules", () => { } }); - it("new circuit and initial device rows share one synchronous transaction", () => { - const repository = new CircuitDeviceRowRepository(); - const originalTransaction = (db as unknown as { transaction: unknown }).transaction; - - let callbackReturnedPromise = false; - const insertedValues: Array> = []; - (db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => { - const fakeTx = { - insert() { - return { - values(values: Record) { - insertedValues.push(values); - return { - run() { - return { changes: 1 }; - }, - }; - }, - }; - }, - }; - const callbackResult = cb(fakeTx); - callbackReturnedPromise = Boolean( - callbackResult && typeof (callbackResult as Promise).then === "function" - ); - }; - - try { - const created = repository.createCircuitWithDeviceRowsTransactional({ - circuit: { - circuitListId: "l1", - sectionId: "s1", - equipmentIdentifier: "-2F1", - displayName: "Steckdosen", - sortOrder: 10, - }, - deviceRows: [ - { - name: "Socket", - displayName: "Steckdose", - quantity: 1, - powerPerUnit: 0.2, - simultaneityFactor: 1, - sortOrder: 15, - }, - { - name: "Manual", - displayName: "Manuell", - quantity: 2, - powerPerUnit: 0.1, - simultaneityFactor: 0.8, - }, - ], - }); - - assert.equal(callbackReturnedPromise, false); - assert.equal(insertedValues.length, 3); - assert.equal(insertedValues[0].id, created.circuitId); - assert.equal(insertedValues[0].isReserve, 0); - assert.equal(insertedValues[1].id, created.rowIds[0]); - assert.equal(insertedValues[1].circuitId, created.circuitId); - assert.equal(insertedValues[1].sortOrder, 15); - assert.equal(insertedValues[2].id, created.rowIds[1]); - assert.equal(insertedValues[2].circuitId, created.circuitId); - assert.equal(insertedValues[2].sortOrder, 20); - } finally { - (db as unknown as { transaction: unknown }).transaction = originalTransaction; - } - }); - it("tracks local edits on linked device rows as overridden fields", async () => { let updatePayload: Record = {}; const current = {