Isolate circuit section transactions
This commit is contained in:
@@ -332,8 +332,10 @@ Implemented foundation:
|
|||||||
- a new circuit and all of its initial device rows use the same transaction store
|
- a new circuit and all of its initial device rows use the same transaction store
|
||||||
- device-row moves, reserve-state updates and optional target creation share one transaction
|
- device-row moves, reserve-state updates and optional target creation share one transaction
|
||||||
- project-device synchronization, disconnect and reconnect use a separate injected transaction store
|
- project-device synchronization, disconnect and reconnect use a separate injected transaction store
|
||||||
|
- section renumbering and circuit reordering use a separate injected transaction store
|
||||||
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
|
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
|
||||||
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
|
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
|
||||||
|
- BMK swaps and section reorder have real SQLite commit and rollback coverage
|
||||||
- circuit and device-row persistence value mapping is separated from the general repositories
|
- circuit and device-row persistence value mapping is separated from the general repositories
|
||||||
|
|
||||||
## Phase 12: Project Revisions and Persistent Undo / Redo
|
## Phase 12: Project Revisions and Persistent Undo / Redo
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"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 tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.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 tests/project-device-row-sync.repository.test.ts tests/circuit-section-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 tests/project-device-row-sync.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 tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "node scripts/db-backup.js",
|
"db:backup": "node scripts/db-backup.js",
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
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 { db } from "../client.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
import {
|
import {
|
||||||
@@ -169,89 +169,4 @@ export class CircuitRepository {
|
|||||||
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
.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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||||
import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.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 { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||||
@@ -28,6 +29,7 @@ export class CircuitWriteService {
|
|||||||
private readonly circuitListRepository: CircuitListRepository;
|
private readonly circuitListRepository: CircuitListRepository;
|
||||||
private readonly deviceRowRepository: CircuitDeviceRowRepository;
|
private readonly deviceRowRepository: CircuitDeviceRowRepository;
|
||||||
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
||||||
|
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
||||||
private readonly projectDeviceRepository: ProjectDeviceRepository;
|
private readonly projectDeviceRepository: ProjectDeviceRepository;
|
||||||
private readonly numberingService: CircuitNumberingService;
|
private readonly numberingService: CircuitNumberingService;
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ export class CircuitWriteService {
|
|||||||
circuitListRepository?: CircuitListRepository;
|
circuitListRepository?: CircuitListRepository;
|
||||||
deviceRowRepository?: CircuitDeviceRowRepository;
|
deviceRowRepository?: CircuitDeviceRowRepository;
|
||||||
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
||||||
|
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
||||||
projectDeviceRepository?: ProjectDeviceRepository;
|
projectDeviceRepository?: ProjectDeviceRepository;
|
||||||
numberingService?: CircuitNumberingService;
|
numberingService?: CircuitNumberingService;
|
||||||
}) {
|
}) {
|
||||||
@@ -45,6 +48,7 @@ export class CircuitWriteService {
|
|||||||
this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository();
|
this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository();
|
||||||
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
||||||
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
|
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
|
||||||
|
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
|
||||||
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
||||||
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
||||||
}
|
}
|
||||||
@@ -98,6 +102,13 @@ export class CircuitWriteService {
|
|||||||
return this.deviceRowTransactionStore;
|
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) {
|
private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) {
|
||||||
if (!linkedProjectDeviceId) {
|
if (!linkedProjectDeviceId) {
|
||||||
return;
|
return;
|
||||||
@@ -451,7 +462,7 @@ export class CircuitWriteService {
|
|||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
// Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps.
|
// Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps.
|
||||||
await this.circuitRepository.updateEquipmentIdentifiersSafely(
|
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
|
||||||
section.circuitListId,
|
section.circuitListId,
|
||||||
finalAssignments,
|
finalAssignments,
|
||||||
sectionId
|
sectionId
|
||||||
@@ -479,7 +490,7 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.circuitRepository.updateEquipmentIdentifiersSafely(
|
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
|
||||||
section.circuitListId,
|
section.circuitListId,
|
||||||
input.identifiers.map((entry) => ({ id: entry.circuitId, equipmentIdentifier: entry.equipmentIdentifier })),
|
input.identifiers.map((entry) => ({ id: entry.circuitId, equipmentIdentifier: entry.equipmentIdentifier })),
|
||||||
sectionId
|
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);
|
return this.circuitRepository.listBySection(sectionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
||||||
import { db } from "../../db/client.js";
|
import { db } from "../../db/client.js";
|
||||||
import { CircuitDeviceRowTransactionRepository } from "../../db/repositories/circuit-device-row-transaction.repository.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({
|
export const circuitWriteService = new CircuitWriteService({
|
||||||
deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db),
|
deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db),
|
||||||
|
circuitSectionTransactionStore: new CircuitSectionTransactionRepository(db),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitSectionTransactionRepository } from "../src/db/repositories/circuit-section-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: "Stromkreis 1",
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-2",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
displayName: "Stromkreis 2",
|
||||||
|
sortOrder: 20,
|
||||||
|
isReserve: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-3",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F3",
|
||||||
|
displayName: "Stromkreis 3",
|
||||||
|
sortOrder: 30,
|
||||||
|
isReserve: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
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();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCircuits(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.orderBy(asc(circuits.id))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit-section transaction repository", () => {
|
||||||
|
it("commits identifier swaps without violating the circuit-list uniqueness constraint", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const persisted = listCircuits(context);
|
||||||
|
const repository = new CircuitSectionTransactionRepository(context.db);
|
||||||
|
|
||||||
|
repository.updateEquipmentIdentifiers(
|
||||||
|
persisted[0].circuitListId,
|
||||||
|
[
|
||||||
|
{ id: "circuit-1", equipmentIdentifier: "-1F2" },
|
||||||
|
{ id: "circuit-2", equipmentIdentifier: "-1F1" },
|
||||||
|
],
|
||||||
|
persisted[0].sectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listCircuits(context).map((circuit) => circuit.equipmentIdentifier),
|
||||||
|
["-1F2", "-1F1", "-1F3"]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(circuitDeviceRows).all()[0].circuitId,
|
||||||
|
"circuit-1"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back temporary and final identifiers when a later update fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const persisted = listCircuits(context);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_second_final_identifier
|
||||||
|
BEFORE UPDATE OF equipment_identifier ON circuits
|
||||||
|
WHEN OLD.id = 'circuit-2' AND NEW.equipment_identifier = '-1F1'
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced identifier failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new CircuitSectionTransactionRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.updateEquipmentIdentifiers(
|
||||||
|
persisted[0].circuitListId,
|
||||||
|
[
|
||||||
|
{ id: "circuit-1", equipmentIdentifier: "-1F2" },
|
||||||
|
{ id: "circuit-2", equipmentIdentifier: "-1F1" },
|
||||||
|
],
|
||||||
|
persisted[0].sectionId
|
||||||
|
),
|
||||||
|
/forced identifier failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listCircuits(context).map((circuit) => circuit.equipmentIdentifier),
|
||||||
|
["-1F1", "-1F2", "-1F3"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a complete section reorder without changing identifiers", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const persisted = listCircuits(context);
|
||||||
|
const repository = new CircuitSectionTransactionRepository(context.db);
|
||||||
|
|
||||||
|
repository.updateSortOrders(persisted[0].sectionId, [
|
||||||
|
"circuit-3",
|
||||||
|
"circuit-1",
|
||||||
|
"circuit-2",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listCircuits(context).map((circuit) => [
|
||||||
|
circuit.id,
|
||||||
|
circuit.sortOrder,
|
||||||
|
circuit.equipmentIdentifier,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
["circuit-1", 20, "-1F1"],
|
||||||
|
["circuit-2", 30, "-1F2"],
|
||||||
|
["circuit-3", 10, "-1F3"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back earlier sort orders when a later update fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const persisted = listCircuits(context);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_last_sort_order
|
||||||
|
BEFORE UPDATE OF sort_order ON circuits
|
||||||
|
WHEN OLD.id = 'circuit-2' AND NEW.sort_order = 30
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced sort failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new CircuitSectionTransactionRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.updateSortOrders(persisted[0].sectionId, [
|
||||||
|
"circuit-3",
|
||||||
|
"circuit-1",
|
||||||
|
"circuit-2",
|
||||||
|
]),
|
||||||
|
/forced sort failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listCircuits(context).map((circuit) => circuit.sortOrder),
|
||||||
|
[10, 20, 30]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
|
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
|
||||||
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
|
|
||||||
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
|
|
||||||
import { db } from "../src/db/client.js";
|
|
||||||
import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js";
|
import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js";
|
||||||
|
|
||||||
describe("circuit write service rules", () => {
|
describe("circuit write service rules", () => {
|
||||||
@@ -320,7 +317,9 @@ describe("circuit write service rules", () => {
|
|||||||
async listByCircuitList() {
|
async listByCircuitList() {
|
||||||
return [{ id: "x1", sectionId: "s2", equipmentIdentifier: "-3F1" }] as never[];
|
return [{ id: "x1", sectionId: "s2", equipmentIdentifier: "-3F1" }] as never[];
|
||||||
},
|
},
|
||||||
async updateEquipmentIdentifiersSafely(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
|
} as never,
|
||||||
|
circuitSectionTransactionStore: {
|
||||||
|
updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
|
||||||
safeUpdatePayload = updates;
|
safeUpdatePayload = updates;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -353,7 +352,9 @@ describe("circuit write service rules", () => {
|
|||||||
async listByCircuitList() {
|
async listByCircuitList() {
|
||||||
return [{ id: "o1", sectionId: "s2", equipmentIdentifier: "-1F2" }] as never[];
|
return [{ id: "o1", sectionId: "s2", equipmentIdentifier: "-1F2" }] as never[];
|
||||||
},
|
},
|
||||||
async updateEquipmentIdentifiersSafely(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
|
} as never,
|
||||||
|
circuitSectionTransactionStore: {
|
||||||
|
updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
|
||||||
safeUpdatePayload = updates;
|
safeUpdatePayload = updates;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -386,7 +387,9 @@ describe("circuit write service rules", () => {
|
|||||||
async listByCircuitList() {
|
async listByCircuitList() {
|
||||||
return [] as never[];
|
return [] as never[];
|
||||||
},
|
},
|
||||||
async updateEquipmentIdentifiersSafely() {
|
} as never,
|
||||||
|
circuitSectionTransactionStore: {
|
||||||
|
updateEquipmentIdentifiers() {
|
||||||
safeCalled += 1;
|
safeCalled += 1;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -692,7 +695,9 @@ describe("circuit write service rules", () => {
|
|||||||
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 },
|
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 },
|
||||||
] as never[];
|
] as never[];
|
||||||
},
|
},
|
||||||
updateSortOrdersSafely(sectionId: string, circuitIds: string[]) {
|
} as never,
|
||||||
|
circuitSectionTransactionStore: {
|
||||||
|
updateSortOrders(sectionId: string, circuitIds: string[]) {
|
||||||
safeReorder = { sectionId, circuitIds };
|
safeReorder = { sectionId, circuitIds };
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -720,7 +725,9 @@ describe("circuit write service rules", () => {
|
|||||||
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 },
|
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 },
|
||||||
] as never[];
|
] as never[];
|
||||||
},
|
},
|
||||||
async updateEquipmentIdentifiersSafely() {
|
} as never,
|
||||||
|
circuitSectionTransactionStore: {
|
||||||
|
updateEquipmentIdentifiers() {
|
||||||
safeCalled = true;
|
safeCalled = true;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -734,121 +741,6 @@ describe("circuit write service rules", () => {
|
|||||||
assert.equal(safeCalled, true);
|
assert.equal(safeCalled, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("safe identifier bulk update uses synchronous transaction callback", async () => {
|
|
||||||
const repository = new CircuitRepository();
|
|
||||||
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
|
|
||||||
|
|
||||||
let callbackReturnedPromise = false;
|
|
||||||
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
|
|
||||||
const fakeTx = {
|
|
||||||
select() {
|
|
||||||
return {
|
|
||||||
from() {
|
|
||||||
return {
|
|
||||||
where() {
|
|
||||||
return {
|
|
||||||
all() {
|
|
||||||
return [{ id: "c1" }, { id: "c2" }];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
update() {
|
|
||||||
return {
|
|
||||||
set() {
|
|
||||||
return {
|
|
||||||
where() {
|
|
||||||
return {
|
|
||||||
run() {
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const result = cb(fakeTx);
|
|
||||||
callbackReturnedPromise = Boolean(result && typeof (result as Promise<unknown>).then === "function");
|
|
||||||
if (callbackReturnedPromise) {
|
|
||||||
throw new Error("Transaction function cannot return a promise");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await repository.updateEquipmentIdentifiersSafely(
|
|
||||||
"l1",
|
|
||||||
[
|
|
||||||
{ id: "c1", equipmentIdentifier: "-1F1" },
|
|
||||||
{ id: "c2", equipmentIdentifier: "-1F2" },
|
|
||||||
],
|
|
||||||
"s1"
|
|
||||||
);
|
|
||||||
assert.equal(callbackReturnedPromise, false);
|
|
||||||
} finally {
|
|
||||||
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("safe circuit reorder uses one synchronous transaction callback", () => {
|
|
||||||
const repository = new CircuitRepository();
|
|
||||||
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
|
|
||||||
|
|
||||||
let callbackReturnedPromise = false;
|
|
||||||
const sortOrders: number[] = [];
|
|
||||||
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
|
|
||||||
const fakeTx = {
|
|
||||||
select() {
|
|
||||||
return {
|
|
||||||
from() {
|
|
||||||
return {
|
|
||||||
where() {
|
|
||||||
return {
|
|
||||||
all() {
|
|
||||||
return [{ id: "c1" }, { id: "c2" }, { id: "c3" }];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
update() {
|
|
||||||
return {
|
|
||||||
set(values: { sortOrder: number }) {
|
|
||||||
sortOrders.push(values.sortOrder);
|
|
||||||
return {
|
|
||||||
where() {
|
|
||||||
return {
|
|
||||||
run() {
|
|
||||||
return { changes: 1 };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const callbackResult = cb(fakeTx);
|
|
||||||
callbackReturnedPromise = Boolean(
|
|
||||||
callbackResult && typeof (callbackResult as Promise<unknown>).then === "function"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
repository.updateSortOrdersSafely("s1", ["c3", "c1", "c2"]);
|
|
||||||
assert.equal(callbackReturnedPromise, false);
|
|
||||||
assert.deepEqual(sortOrders, [10, 20, 30]);
|
|
||||||
} finally {
|
|
||||||
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tracks local edits on linked device rows as overridden fields", async () => {
|
it("tracks local edits on linked device rows as overridden fields", async () => {
|
||||||
let updatePayload: Record<string, unknown> = {};
|
let updatePayload: Record<string, unknown> = {};
|
||||||
const current = {
|
const current = {
|
||||||
|
|||||||
Reference in New Issue
Block a user