diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 4fd1bf7..4083451 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -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 diff --git a/package.json b/package.json index d8918dc..db5804c 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts 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", diff --git a/src/db/repositories/circuit-section-transaction.repository.ts b/src/db/repositories/circuit-section-transaction.repository.ts new file mode 100644 index 0000000..52dc97f --- /dev/null +++ b/src/db/repositories/circuit-section-transaction.repository.ts @@ -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(); + } + }); + } +} diff --git a/src/db/repositories/circuit.repository.ts b/src/db/repositories/circuit.repository.ts index 69d7da6..d7eef0d 100644 --- a/src/db/repositories/circuit.repository.ts +++ b/src/db/repositories/circuit.repository.ts @@ -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(); - } - }); - } } diff --git a/src/domain/ports/circuit-section-transaction.store.ts b/src/domain/ports/circuit-section-transaction.store.ts new file mode 100644 index 0000000..c1bba59 --- /dev/null +++ b/src/domain/ports/circuit-section-transaction.store.ts @@ -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; +} diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index dd27fa4..a83cccc 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -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); } } diff --git a/src/server/composition/circuit-write-service.ts b/src/server/composition/circuit-write-service.ts index f006854..52265b2 100644 --- a/src/server/composition/circuit-write-service.ts +++ b/src/server/composition/circuit-write-service.ts @@ -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), }); diff --git a/tests/circuit-section-transaction.repository.test.ts b/tests/circuit-section-transaction.repository.test.ts new file mode 100644 index 0000000..36a4993 --- /dev/null +++ b/tests/circuit-section-transaction.repository.test.ts @@ -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(); + } + }); +}); diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts index 1656b86..b0e967a 100644 --- a/tests/circuit-write.rules.test.ts +++ b/tests/circuit-write.rules.test.ts @@ -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).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).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 = {}; const current = {