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
|
||||
- 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
|
||||
- section renumbering and circuit reordering use a separate injected transaction store
|
||||
- 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
|
||||
- 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
|
||||
|
||||
## Phase 12: Project Revisions and Persistent Undo / Redo
|
||||
|
||||
+2
-2
@@ -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 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",
|
||||
"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 tests/circuit-section-transaction.repository.test.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"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 { 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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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 { describe, it } from "node:test";
|
||||
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";
|
||||
|
||||
describe("circuit write service rules", () => {
|
||||
@@ -320,7 +317,9 @@ describe("circuit write service rules", () => {
|
||||
async listByCircuitList() {
|
||||
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;
|
||||
},
|
||||
} as never,
|
||||
@@ -353,7 +352,9 @@ describe("circuit write service rules", () => {
|
||||
async listByCircuitList() {
|
||||
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;
|
||||
},
|
||||
} as never,
|
||||
@@ -386,7 +387,9 @@ describe("circuit write service rules", () => {
|
||||
async listByCircuitList() {
|
||||
return [] as never[];
|
||||
},
|
||||
async updateEquipmentIdentifiersSafely() {
|
||||
} as never,
|
||||
circuitSectionTransactionStore: {
|
||||
updateEquipmentIdentifiers() {
|
||||
safeCalled += 1;
|
||||
},
|
||||
} as never,
|
||||
@@ -692,7 +695,9 @@ describe("circuit write service rules", () => {
|
||||
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 },
|
||||
] as never[];
|
||||
},
|
||||
updateSortOrdersSafely(sectionId: string, circuitIds: string[]) {
|
||||
} as never,
|
||||
circuitSectionTransactionStore: {
|
||||
updateSortOrders(sectionId: string, circuitIds: string[]) {
|
||||
safeReorder = { sectionId, circuitIds };
|
||||
},
|
||||
} as never,
|
||||
@@ -720,7 +725,9 @@ describe("circuit write service rules", () => {
|
||||
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 },
|
||||
] as never[];
|
||||
},
|
||||
async updateEquipmentIdentifiersSafely() {
|
||||
} as never,
|
||||
circuitSectionTransactionStore: {
|
||||
updateEquipmentIdentifiers() {
|
||||
safeCalled = true;
|
||||
},
|
||||
} as never,
|
||||
@@ -734,121 +741,6 @@ describe("circuit write service rules", () => {
|
||||
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 () => {
|
||||
let updatePayload: Record<string, unknown> = {};
|
||||
const current = {
|
||||
|
||||
Reference in New Issue
Block a user