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
@@ -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
View File
@@ -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 -86
View File
@@ -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;
}
+17 -3
View File
@@ -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();
}
});
});
+15 -123
View File
@@ -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 = {