Isolate circuit section transactions

This commit is contained in:
2026-07-23 19:02:38 +02:00
parent 91baa6c76c
commit 9be94028b3
9 changed files with 377 additions and 214 deletions
@@ -0,0 +1,116 @@
import { and, eq, inArray } from "drizzle-orm";
import type { CircuitSectionTransactionStore } from "../../domain/ports/circuit-section-transaction.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuits } from "../schema/circuits.js";
export class CircuitSectionTransactionRepository
implements CircuitSectionTransactionStore
{
constructor(private readonly database: AppDatabase) {}
updateSortOrders(sectionId: string, orderedCircuitIds: string[]) {
if (orderedCircuitIds.length === 0) {
return;
}
if (new Set(orderedCircuitIds).size !== orderedCircuitIds.length) {
throw new Error(
"Stromkreise dürfen in der Reihenfolge nicht mehrfach vorkommen."
);
}
this.database.transaction((tx) => {
const sectionCircuits = tx
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.sectionId, sectionId))
.all();
const sectionCircuitIds = new Set(
sectionCircuits.map((circuit) => circuit.id)
);
if (
sectionCircuits.length !== orderedCircuitIds.length ||
orderedCircuitIds.some((circuitId) => !sectionCircuitIds.has(circuitId))
) {
throw new Error(
"Die Reihenfolge muss alle Stromkreise des Bereichs enthalten."
);
}
for (let index = 0; index < orderedCircuitIds.length; index += 1) {
const result = tx
.update(circuits)
.set({ sortOrder: (index + 1) * 10 })
.where(
and(
eq(circuits.sectionId, sectionId),
eq(circuits.id, orderedCircuitIds[index])
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"Ein Stromkreis wurde vor Abschluss der Sortierung verändert."
);
}
}
});
}
updateEquipmentIdentifiers(
circuitListId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>,
tempNamespace: string
) {
if (updates.length === 0) {
return;
}
this.database.transaction((tx) => {
const ids = updates.map((entry) => entry.id);
const existing = tx
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, circuitListId),
inArray(circuits.id, ids)
)
)
.all();
if (existing.length !== ids.length) {
throw new Error(
"One or more circuit ids are invalid for circuit list."
);
}
const stamp = Date.now();
for (let index = 0; index < updates.length; index += 1) {
const entry = updates[index];
const tempIdentifier = `__tmp_renumber_${tempNamespace}_${stamp}_${index}`;
tx
.update(circuits)
.set({ equipmentIdentifier: tempIdentifier })
.where(
and(
eq(circuits.circuitListId, circuitListId),
eq(circuits.id, entry.id)
)
)
.run();
}
for (const entry of updates) {
tx
.update(circuits)
.set({ equipmentIdentifier: entry.equipmentIdentifier })
.where(
and(
eq(circuits.circuitListId, circuitListId),
eq(circuits.id, entry.id)
)
)
.run();
}
});
}
}
+1 -86
View File
@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import { and, asc, eq, inArray, ne } from "drizzle-orm";
import { and, asc, eq, ne } from "drizzle-orm";
import { db } from "../client.js";
import { circuits } from "../schema/circuits.js";
import {
@@ -169,89 +169,4 @@ export class CircuitRepository {
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
}
updateSortOrdersSafely(sectionId: string, orderedCircuitIds: string[]) {
if (orderedCircuitIds.length === 0) {
return;
}
if (new Set(orderedCircuitIds).size !== orderedCircuitIds.length) {
throw new Error("Stromkreise dürfen in der Reihenfolge nicht mehrfach vorkommen.");
}
db.transaction((tx) => {
const sectionCircuits = tx
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.sectionId, sectionId))
.all();
const sectionCircuitIds = new Set(sectionCircuits.map((circuit) => circuit.id));
if (
sectionCircuits.length !== orderedCircuitIds.length ||
orderedCircuitIds.some((circuitId) => !sectionCircuitIds.has(circuitId))
) {
throw new Error("Die Reihenfolge muss alle Stromkreise des Bereichs enthalten.");
}
for (let index = 0; index < orderedCircuitIds.length; index += 1) {
const result = tx
.update(circuits)
.set({ sortOrder: (index + 1) * 10 })
.where(
and(
eq(circuits.sectionId, sectionId),
eq(circuits.id, orderedCircuitIds[index])
)
)
.run();
if (result.changes !== 1) {
throw new Error("Ein Stromkreis wurde vor Abschluss der Sortierung verändert.");
}
}
});
}
async updateEquipmentIdentifiersSafely(
circuitListId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>,
tempNamespace: string
) {
if (updates.length === 0) {
return;
}
// better-sqlite3 transactions are synchronous callbacks. Do not make this callback
// async or return a Promise, otherwise statements may run outside the transaction scope.
db.transaction((tx) => {
const ids = updates.map((entry) => entry.id);
const existing = tx
.select({ id: circuits.id })
.from(circuits)
.where(and(eq(circuits.circuitListId, circuitListId), inArray(circuits.id, ids)))
.all();
if (existing.length !== ids.length) {
throw new Error("One or more circuit ids are invalid for circuit list.");
}
// Direct identifier swaps can violate UNIQUE(circuit_list_id, equipment_identifier)
// mid-update (for example A->B while B->A). Two-phase strategy prevents that:
// 1) assign unique temporary identifiers for all affected circuits
// 2) assign final user-visible identifiers
const stamp = Date.now();
for (let index = 0; index < updates.length; index += 1) {
const entry = updates[index];
const tempIdentifier = `__tmp_renumber_${tempNamespace}_${stamp}_${index}`;
tx
.update(circuits)
.set({ equipmentIdentifier: tempIdentifier })
.where(and(eq(circuits.circuitListId, circuitListId), eq(circuits.id, entry.id)))
.run();
}
for (const entry of updates) {
tx
.update(circuits)
.set({ equipmentIdentifier: entry.equipmentIdentifier })
.where(and(eq(circuits.circuitListId, circuitListId), eq(circuits.id, entry.id)))
.run();
}
});
}
}
@@ -0,0 +1,8 @@
export interface CircuitSectionTransactionStore {
updateEquipmentIdentifiers(
circuitListId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>,
tempNamespace: string
): void;
updateSortOrders(sectionId: string, orderedCircuitIds: string[]): void;
}
+17 -3
View File
@@ -1,5 +1,6 @@
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js";
import type { CircuitSectionTransactionStore } from "../ports/circuit-section-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";
@@ -28,6 +29,7 @@ export class CircuitWriteService {
private readonly circuitListRepository: CircuitListRepository;
private readonly deviceRowRepository: CircuitDeviceRowRepository;
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
private readonly projectDeviceRepository: ProjectDeviceRepository;
private readonly numberingService: CircuitNumberingService;
@@ -37,6 +39,7 @@ export class CircuitWriteService {
circuitListRepository?: CircuitListRepository;
deviceRowRepository?: CircuitDeviceRowRepository;
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
projectDeviceRepository?: ProjectDeviceRepository;
numberingService?: CircuitNumberingService;
}) {
@@ -45,6 +48,7 @@ export class CircuitWriteService {
this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository();
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
}
@@ -98,6 +102,13 @@ export class CircuitWriteService {
return this.deviceRowTransactionStore;
}
private getCircuitSectionTransactionStore() {
if (!this.circuitSectionTransactionStore) {
throw new Error("Circuit-section transactions are not configured.");
}
return this.circuitSectionTransactionStore;
}
private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) {
if (!linkedProjectDeviceId) {
return;
@@ -451,7 +462,7 @@ export class CircuitWriteService {
index += 1;
}
// Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps.
await this.circuitRepository.updateEquipmentIdentifiersSafely(
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
section.circuitListId,
finalAssignments,
sectionId
@@ -479,7 +490,7 @@ export class CircuitWriteService {
}
}
await this.circuitRepository.updateEquipmentIdentifiersSafely(
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
section.circuitListId,
input.identifiers.map((entry) => ({ id: entry.circuitId, equipmentIdentifier: entry.equipmentIdentifier })),
sectionId
@@ -504,7 +515,10 @@ export class CircuitWriteService {
}
}
this.circuitRepository.updateSortOrdersSafely(sectionId, input.orderedCircuitIds);
this.getCircuitSectionTransactionStore().updateSortOrders(
sectionId,
input.orderedCircuitIds
);
return this.circuitRepository.listBySection(sectionId);
}
}
@@ -1,7 +1,9 @@
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";
import { CircuitSectionTransactionRepository } from "../../db/repositories/circuit-section-transaction.repository.js";
export const circuitWriteService = new CircuitWriteService({
deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db),
circuitSectionTransactionStore: new CircuitSectionTransactionRepository(db),
});