diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 75e465e..dcae871 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -328,6 +328,9 @@ Implemented foundation: - SQLite foreign-key enforcement is enabled explicitly for every context - 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 +- 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 ## Phase 12: Project Revisions and Persistent Undo / Redo diff --git a/package.json b/package.json index 9f79571..bd6a618 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts", - "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts", + "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts", + "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "node scripts/db-backup.js", diff --git a/scripts/dev-add-manual-circuit-row.ts b/scripts/dev-add-manual-circuit-row.ts index a6b0299..80d65d4 100644 --- a/scripts/dev-add-manual-circuit-row.ts +++ b/scripts/dev-add-manual-circuit-row.ts @@ -1,4 +1,5 @@ -import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js"; +import { db } from "../src/db/client.js"; +import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js"; import { CircuitRepository } from "../src/db/repositories/circuit.repository.js"; async function run() { @@ -9,7 +10,7 @@ async function run() { } const circuitRepository = new CircuitRepository(); - const rowRepository = new CircuitDeviceRowRepository(); + const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db); const circuit = await circuitRepository.findById(circuitId); if (!circuit) { @@ -17,10 +18,8 @@ async function run() { process.exit(1); } - const rowCount = await rowRepository.countByCircuit(circuitId); - const createdRowId = await rowRepository.create({ + const createdRowId = rowTransactionRepository.createInCircuit({ circuitId, - sortOrder: (rowCount + 1) * 10, name: "Test sub device", displayName: "Beleuchtung WC", phaseType: "single_phase", diff --git a/scripts/dev-delete-circuit-row.ts b/scripts/dev-delete-circuit-row.ts index 3793128..b89006f 100644 --- a/scripts/dev-delete-circuit-row.ts +++ b/scripts/dev-delete-circuit-row.ts @@ -1,4 +1,6 @@ +import { db } from "../src/db/client.js"; import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js"; +import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js"; async function run() { const rowId = process.argv[2]; @@ -8,13 +10,14 @@ async function run() { } const rowRepository = new CircuitDeviceRowRepository(); + const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db); const row = await rowRepository.findById(rowId); if (!row) { console.error(`Row not found: ${rowId}`); process.exit(1); } - await rowRepository.delete(rowId); + rowTransactionRepository.deleteFromCircuit(rowId, row.circuitId); console.log(`Deleted row id: ${rowId}`); } diff --git a/src/db/repositories/circuit-device-row-transaction.repository.ts b/src/db/repositories/circuit-device-row-transaction.repository.ts new file mode 100644 index 0000000..52f27e5 --- /dev/null +++ b/src/db/repositories/circuit-device-row-transaction.repository.ts @@ -0,0 +1,93 @@ +import crypto from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import type { + CircuitDeviceRowTransactionStore, + CreateCircuitDeviceRowTransactionInput, +} 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 { + toCircuitDeviceRowCreateValues, +} from "./circuit-device-row.persistence.js"; + +export class CircuitDeviceRowTransactionRepository + implements CircuitDeviceRowTransactionStore +{ + constructor(private readonly database: AppDatabase) {} + + createInCircuit(input: CreateCircuitDeviceRowTransactionInput) { + const id = crypto.randomUUID(); + this.database.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(toCircuitDeviceRowCreateValues(id, { ...input, sortOrder })) + .run(); + tx + .update(circuits) + .set({ isReserve: 0 }) + .where(eq(circuits.id, input.circuitId)) + .run(); + }); + return id; + } + + deleteFromCircuit(rowId: string, expectedCircuitId: string) { + this.database.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(); + }); + } +} diff --git a/src/db/repositories/circuit-device-row.persistence.ts b/src/db/repositories/circuit-device-row.persistence.ts new file mode 100644 index 0000000..2a6fc44 --- /dev/null +++ b/src/db/repositories/circuit-device-row.persistence.ts @@ -0,0 +1,100 @@ +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; + +export interface CircuitDeviceRowUpdateInput { + linkedProjectDeviceId?: string; + 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; +} + +export type CircuitDeviceRowPatchInput = Partial & { + sortOrder?: number; +}; + +export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput { + circuitId: string; + linkedProjectDeviceId?: string; + legacyConsumerId?: string; + sortOrder: number; +} + +export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInput) { + return { + linkedProjectDeviceId: input.linkedProjectDeviceId ?? null, + 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, + }; +} + +export function toCircuitDeviceRowPatchValues(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; +} + +export function toCircuitDeviceRowCreateValues( + id: string, + input: CircuitDeviceRowCreateInput +) { + return { + id, + circuitId: input.circuitId, + legacyConsumerId: input.legacyConsumerId ?? null, + sortOrder: input.sortOrder, + ...toCircuitDeviceRowUpdateValues(input), + }; +} diff --git a/src/db/repositories/circuit-device-row.repository.ts b/src/db/repositories/circuit-device-row.repository.ts index 601c260..669df08 100644 --- a/src/db/repositories/circuit-device-row.repository.ts +++ b/src/db/repositories/circuit-device-row.repository.ts @@ -5,41 +5,25 @@ import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuits } from "../schema/circuits.js"; import { distributionBoards } from "../schema/distribution-boards.js"; +import { + toCircuitDeviceRowCreateValues, + toCircuitDeviceRowPatchValues, + toCircuitDeviceRowUpdateValues, + type CircuitDeviceRowCreateInput, + type CircuitDeviceRowPatchInput, + type CircuitDeviceRowUpdateInput, +} from "./circuit-device-row.persistence.js"; import { toCircuitCreateValues, type CircuitCreatePersistenceInput, } from "./circuit.repository.js"; -export interface CircuitDeviceRowUpdateInput { - linkedProjectDeviceId?: string; - 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; -} - -export type CircuitDeviceRowPatchInput = Partial & { - sortOrder?: number; -}; - -export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput { - circuitId: string; - linkedProjectDeviceId?: string; - legacyConsumerId?: string; - sortOrder: number; -} +export type { + CircuitDeviceRowCreateInput, + CircuitDeviceRowPatchInput, + CircuitDeviceRowUpdateInput, +} from "./circuit-device-row.persistence.js"; +export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js"; export interface CircuitDeviceRowsBulkMoveInput { rows: Array<{ id: string; expectedCircuitId: string }>; @@ -60,71 +44,6 @@ export interface CircuitWithDeviceRowsCreateInput { >; } -function toUpdateValues(input: CircuitDeviceRowUpdateInput) { - return { - linkedProjectDeviceId: input.linkedProjectDeviceId ?? null, - 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, - }; -} - -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; -} - -export function toCircuitDeviceRowCreateValues(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); @@ -212,41 +131,6 @@ export class CircuitDeviceRowRepository { return id; } - createInCircuitTransactional( - input: Omit & { 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(toCircuitDeviceRowCreateValues(id, { ...input, sortOrder })) - .run(); - tx.update(circuits).set({ isReserve: 0 }).where(eq(circuits.id, input.circuitId)).run(); - }); - return id; - } - createCircuitWithDeviceRowsTransactional(input: CircuitWithDeviceRowsCreateInput) { if (input.deviceRows.length === 0) { throw new Error("Mindestens eine Gerätezeile ist erforderlich."); @@ -289,12 +173,12 @@ export class CircuitDeviceRowRepository { ) { await db .update(circuitDeviceRows) - .set(toUpdateValues(input)) + .set(toCircuitDeviceRowUpdateValues(input)) .where(eq(circuitDeviceRows.id, rowId)); } async updateFields(rowId: string, input: CircuitDeviceRowPatchInput) { - const values = toPatchValues(input); + const values = toCircuitDeviceRowPatchValues(input); if (Object.keys(values).length === 0) { return; } @@ -309,7 +193,7 @@ export class CircuitDeviceRowRepository { for (const change of changes) { const result = tx .update(circuitDeviceRows) - .set(toUpdateValues(change.input)) + .set(toCircuitDeviceRowUpdateValues(change.input)) .where( and( eq(circuitDeviceRows.id, change.rowId), @@ -363,44 +247,6 @@ 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) diff --git a/src/domain/ports/circuit-device-row-transaction.store.ts b/src/domain/ports/circuit-device-row-transaction.store.ts new file mode 100644 index 0000000..504d0b9 --- /dev/null +++ b/src/domain/ports/circuit-device-row-transaction.store.ts @@ -0,0 +1,26 @@ +export interface CreateCircuitDeviceRowTransactionInput { + circuitId: string; + linkedProjectDeviceId?: 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; +} + +export interface CircuitDeviceRowTransactionStore { + createInCircuit(input: CreateCircuitDeviceRowTransactionInput): 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 a40d782..3f8372e 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -1,4 +1,5 @@ import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; +import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js"; import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; @@ -26,6 +27,7 @@ export class CircuitWriteService { private readonly circuitSectionRepository: CircuitSectionRepository; private readonly circuitListRepository: CircuitListRepository; private readonly deviceRowRepository: CircuitDeviceRowRepository; + private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore; private readonly projectDeviceRepository: ProjectDeviceRepository; private readonly numberingService: CircuitNumberingService; @@ -34,6 +36,7 @@ export class CircuitWriteService { circuitSectionRepository?: CircuitSectionRepository; circuitListRepository?: CircuitListRepository; deviceRowRepository?: CircuitDeviceRowRepository; + deviceRowTransactionStore?: CircuitDeviceRowTransactionStore; projectDeviceRepository?: ProjectDeviceRepository; numberingService?: CircuitNumberingService; }) { @@ -41,6 +44,7 @@ export class CircuitWriteService { this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository(); this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository(); this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository(); + this.deviceRowTransactionStore = deps?.deviceRowTransactionStore; this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository(); this.numberingService = deps?.numberingService ?? new CircuitNumberingService(); } @@ -87,6 +91,13 @@ export class CircuitWriteService { } } + private getDeviceRowTransactionStore() { + if (!this.deviceRowTransactionStore) { + throw new Error("Circuit device-row transactions are not configured."); + } + return this.deviceRowTransactionStore; + } + private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) { if (!linkedProjectDeviceId) { return; @@ -204,7 +215,7 @@ export class CircuitWriteService { } await this.assertValidLinkedProjectDevice(circuitId, input.linkedProjectDeviceId); - const rowId = this.deviceRowRepository.createInCircuitTransactional({ + const rowId = this.getDeviceRowTransactionStore().createInCircuit({ circuitId, linkedProjectDeviceId: input.linkedProjectDeviceId, sortOrder: input.sortOrder, @@ -266,7 +277,7 @@ export class CircuitWriteService { if (!circuit) { throw new Error("Invalid circuit id."); } - this.deviceRowRepository.deleteFromCircuitTransactional(rowId, circuit.id); + this.getDeviceRowTransactionStore().deleteFromCircuit(rowId, circuit.id); } async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) { diff --git a/src/server/composition/circuit-write-service.ts b/src/server/composition/circuit-write-service.ts new file mode 100644 index 0000000..f006854 --- /dev/null +++ b/src/server/composition/circuit-write-service.ts @@ -0,0 +1,7 @@ +import { CircuitWriteService } from "../../domain/services/circuit-write.service.js"; +import { db } from "../../db/client.js"; +import { CircuitDeviceRowTransactionRepository } from "../../db/repositories/circuit-device-row-transaction.repository.js"; + +export const circuitWriteService = new CircuitWriteService({ + deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db), +}); diff --git a/src/server/controllers/circuit-device-row.controller.ts b/src/server/controllers/circuit-device-row.controller.ts index 17a0000..7a587ad 100644 --- a/src/server/controllers/circuit-device-row.controller.ts +++ b/src/server/controllers/circuit-device-row.controller.ts @@ -1,5 +1,5 @@ import type { Request, Response } from "express"; -import { CircuitWriteService } from "../../domain/services/circuit-write.service.js"; +import { circuitWriteService } from "../composition/circuit-write-service.js"; import { createCircuitDeviceRowSchema, moveCircuitDeviceRowsBulkSchema, @@ -7,8 +7,6 @@ import { updateCircuitDeviceRowSchema, } from "../../shared/validation/circuit.schemas.js"; -const circuitWriteService = new CircuitWriteService(); - export async function createCircuitDeviceRow(req: Request, res: Response) { const { circuitId } = req.params; if (typeof circuitId !== "string") { diff --git a/src/server/controllers/circuit-section.controller.ts b/src/server/controllers/circuit-section.controller.ts index 3332cb8..46c1a2f 100644 --- a/src/server/controllers/circuit-section.controller.ts +++ b/src/server/controllers/circuit-section.controller.ts @@ -1,12 +1,10 @@ import type { Request, Response } from "express"; -import { CircuitWriteService } from "../../domain/services/circuit-write.service.js"; +import { circuitWriteService } from "../composition/circuit-write-service.js"; import { reorderSectionCircuitsSchema, updateSectionEquipmentIdentifiersSchema, } from "../../shared/validation/circuit.schemas.js"; -const circuitWriteService = new CircuitWriteService(); - export async function renumberCircuitSection(req: Request, res: Response) { const { sectionId } = req.params; if (typeof sectionId !== "string") { diff --git a/src/server/controllers/circuit.controller.ts b/src/server/controllers/circuit.controller.ts index 8ab692d..c29761e 100644 --- a/src/server/controllers/circuit.controller.ts +++ b/src/server/controllers/circuit.controller.ts @@ -1,13 +1,11 @@ import type { Request, Response } from "express"; -import { CircuitWriteService } from "../../domain/services/circuit-write.service.js"; +import { circuitWriteService } from "../composition/circuit-write-service.js"; import { createCircuitSchema, createCircuitWithDeviceRowsSchema, updateCircuitSchema, } from "../../shared/validation/circuit.schemas.js"; -const circuitWriteService = new CircuitWriteService(); - export async function createCircuit(req: Request, res: Response) { const { projectId, circuitListId } = req.params; if (typeof projectId !== "string" || typeof circuitListId !== "string") { diff --git a/tests/circuit-device-row-transaction.repository.test.ts b/tests/circuit-device-row-transaction.repository.test.ts new file mode 100644 index 0000000..247e6b4 --- /dev/null +++ b/tests/circuit-device-row-transaction.repository.test.ts @@ -0,0 +1,177 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js"; +import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; +import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { projects } from "../src/db/schema/projects.js"; + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run(); + const board = new DistributionBoardRepository( + context.db + ).createWithCircuitListAndDefaultSections( + "project-1", + "UV-01" + ); + const [section] = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, board.id)) + .limit(1) + .all(); + context.db + .insert(circuits) + .values({ + id: "circuit-1", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F1", + displayName: "Reserve", + sortOrder: 10, + isReserve: 1, + }) + .run(); + return context; +} + +function insertDeviceRow(context: DatabaseContext) { + context.db + .insert(circuitDeviceRows) + .values({ + id: "row-1", + circuitId: "circuit-1", + sortOrder: 10, + name: "Leuchte", + displayName: "Leuchte", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }) + .run(); + context.db + .update(circuits) + .set({ isReserve: 0 }) + .where(eq(circuits.id, "circuit-1")) + .run(); +} + +describe("circuit device-row transaction repository", () => { + it("commits a new device row and clears the circuit reserve status together", () => { + const context = createTestDatabase(); + try { + const repository = new CircuitDeviceRowTransactionRepository(context.db); + const rowId = repository.createInCircuit({ + circuitId: "circuit-1", + name: "Steckdose", + displayName: "Steckdose", + quantity: 1, + powerPerUnit: 0.2, + simultaneityFactor: 1, + }); + + const [row] = context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, rowId)) + .all(); + const [circuit] = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .all(); + + assert.equal(row.circuitId, "circuit-1"); + assert.equal(row.sortOrder, 10); + assert.equal(circuit.isReserve, 0); + } finally { + context.close(); + } + }); + + it("rolls back the row insert when clearing the reserve status fails", () => { + const context = createTestDatabase(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_reserve_clear + BEFORE UPDATE OF is_reserve ON circuits + WHEN NEW.is_reserve = 0 + BEGIN + SELECT RAISE(ABORT, 'forced reserve clear failure'); + END; + `); + const repository = new CircuitDeviceRowTransactionRepository(context.db); + + assert.throws( + () => + repository.createInCircuit({ + circuitId: "circuit-1", + name: "Steckdose", + displayName: "Steckdose", + quantity: 1, + powerPerUnit: 0.2, + simultaneityFactor: 1, + }), + /forced reserve clear failure/ + ); + + assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0); + assert.equal(context.db.select().from(circuits).all()[0].isReserve, 1); + } finally { + context.close(); + } + }); + + it("commits the last-row deletion and activates the circuit reserve status together", () => { + const context = createTestDatabase(); + try { + insertDeviceRow(context); + const repository = new CircuitDeviceRowTransactionRepository(context.db); + + repository.deleteFromCircuit("row-1", "circuit-1"); + + assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0); + assert.equal(context.db.select().from(circuits).all()[0].isReserve, 1); + } finally { + context.close(); + } + }); + + it("rolls back the row deletion when activating the reserve status fails", () => { + const context = createTestDatabase(); + try { + insertDeviceRow(context); + context.sqlite.exec(` + CREATE TRIGGER fail_reserve_activation + BEFORE UPDATE OF is_reserve ON circuits + WHEN NEW.is_reserve = 1 + BEGIN + SELECT RAISE(ABORT, 'forced reserve activation failure'); + END; + `); + const repository = new CircuitDeviceRowTransactionRepository(context.db); + + assert.throws( + () => repository.deleteFromCircuit("row-1", "circuit-1"), + /forced reserve activation failure/ + ); + + assert.equal(context.db.select().from(circuitDeviceRows).all().length, 1); + assert.equal(context.db.select().from(circuits).all()[0].isReserve, 0); + } finally { + context.close(); + } + }); +}); diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts index bab9834..1e00fe2 100644 --- a/tests/circuit-write.rules.test.ts +++ b/tests/circuit-write.rules.test.ts @@ -125,7 +125,9 @@ describe("circuit write service rules", () => { async findById() { return { id: "r1", circuitId: "c1" } as never; }, - deleteFromCircuitTransactional(rowId: string, circuitId: string) { + } as never, + deviceRowTransactionStore: { + deleteFromCircuit(rowId: string, circuitId: string) { transactionalDelete = { rowId, circuitId }; }, } as never, @@ -170,14 +172,16 @@ describe("circuit write service rules", () => { }, } as never, deviceRowRepository: { - createInCircuitTransactional(input: typeof transactionalCreate) { - transactionalCreate = input; - return "row1"; - }, async findById() { return { id: "row1" } as never; }, } as never, + deviceRowTransactionStore: { + createInCircuit(input: typeof transactionalCreate) { + transactionalCreate = input; + return "row1"; + }, + } as never, circuitListRepository: {} as never, circuitSectionRepository: {} as never, projectDeviceRepository: { @@ -921,163 +925,6 @@ 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).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).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("new circuit and initial device rows share one synchronous transaction", () => { const repository = new CircuitDeviceRowRepository(); const originalTransaction = (db as unknown as { transaction: unknown }).transaction;