diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 92a1565..4fd1bf7 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -331,7 +331,9 @@ Implemented foundation: - circuit-device-row creation/deletion and reserve-state changes use an explicitly injected 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 +- project-device synchronization, disconnect and reconnect 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 - 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 bd6a618..d8918dc 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", - "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", + "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", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "node scripts/db-backup.js", diff --git a/src/db/repositories/circuit-device-row.repository.ts b/src/db/repositories/circuit-device-row.repository.ts index 3ccc3d7..e2cdafa 100644 --- a/src/db/repositories/circuit-device-row.repository.ts +++ b/src/db/repositories/circuit-device-row.repository.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { and, asc, eq, inArray, isNull } from "drizzle-orm"; +import { and, asc, eq, inArray } from "drizzle-orm"; import { db } from "../client.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; @@ -125,64 +125,6 @@ export class CircuitDeviceRowRepository { await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId)); } - updateLinkedRowsTransactional( - projectDeviceId: string, - changes: Array<{ rowId: string; input: CircuitDeviceRowUpdateInput }> - ) { - db.transaction((tx) => { - for (const change of changes) { - const result = tx - .update(circuitDeviceRows) - .set(toCircuitDeviceRowUpdateValues(change.input)) - .where( - and( - eq(circuitDeviceRows.id, change.rowId), - eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId) - ) - ) - .run(); - if (result.changes !== 1) { - throw new Error("A linked row changed before the operation could be completed."); - } - } - }); - } - - disconnectLinkedRowsTransactional(projectDeviceId: string, rowIds: string[]) { - db.transaction((tx) => { - for (const rowId of rowIds) { - const result = tx - .update(circuitDeviceRows) - .set({ linkedProjectDeviceId: null }) - .where( - and( - eq(circuitDeviceRows.id, rowId), - eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId) - ) - ) - .run(); - if (result.changes !== 1) { - throw new Error("A linked row changed before the operation could be completed."); - } - } - }); - } - - reconnectRowsTransactional(projectDeviceId: string, rowIds: string[]) { - db.transaction((tx) => { - for (const rowId of rowIds) { - const result = tx - .update(circuitDeviceRows) - .set({ linkedProjectDeviceId: projectDeviceId }) - .where(and(eq(circuitDeviceRows.id, rowId), isNull(circuitDeviceRows.linkedProjectDeviceId))) - .run(); - if (result.changes !== 1) { - throw new Error("A disconnected row changed before the operation could be undone."); - } - } - }); - } - async delete(rowId: string) { await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)); } diff --git a/src/db/repositories/project-device-row-sync.repository.ts b/src/db/repositories/project-device-row-sync.repository.ts new file mode 100644 index 0000000..752af25 --- /dev/null +++ b/src/db/repositories/project-device-row-sync.repository.ts @@ -0,0 +1,81 @@ +import { and, eq, isNull } from "drizzle-orm"; +import type { + ProjectDeviceLinkedRowValues, + ProjectDeviceRowSyncStore, +} from "../../domain/ports/project-device-row-sync.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { toCircuitDeviceRowUpdateValues } from "./circuit-device-row.persistence.js"; + +export class ProjectDeviceRowSyncRepository implements ProjectDeviceRowSyncStore { + constructor(private readonly database: AppDatabase) {} + + updateLinkedRows( + projectDeviceId: string, + changes: Array<{ rowId: string; input: ProjectDeviceLinkedRowValues }> + ) { + this.database.transaction((tx) => { + for (const change of changes) { + const result = tx + .update(circuitDeviceRows) + .set(toCircuitDeviceRowUpdateValues(change.input)) + .where( + and( + eq(circuitDeviceRows.id, change.rowId), + eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId) + ) + ) + .run(); + if (result.changes !== 1) { + throw new Error( + "A linked row changed before the operation could be completed." + ); + } + } + }); + } + + disconnectLinkedRows(projectDeviceId: string, rowIds: string[]) { + this.database.transaction((tx) => { + for (const rowId of rowIds) { + const result = tx + .update(circuitDeviceRows) + .set({ linkedProjectDeviceId: null }) + .where( + and( + eq(circuitDeviceRows.id, rowId), + eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId) + ) + ) + .run(); + if (result.changes !== 1) { + throw new Error( + "A linked row changed before the operation could be completed." + ); + } + } + }); + } + + reconnectRows(projectDeviceId: string, rowIds: string[]) { + this.database.transaction((tx) => { + for (const rowId of rowIds) { + const result = tx + .update(circuitDeviceRows) + .set({ linkedProjectDeviceId: projectDeviceId }) + .where( + and( + eq(circuitDeviceRows.id, rowId), + isNull(circuitDeviceRows.linkedProjectDeviceId) + ) + ) + .run(); + if (result.changes !== 1) { + throw new Error( + "A disconnected row changed before the operation could be undone." + ); + } + } + }); + } +} diff --git a/src/domain/ports/project-device-row-sync.store.ts b/src/domain/ports/project-device-row-sync.store.ts new file mode 100644 index 0000000..717069d --- /dev/null +++ b/src/domain/ports/project-device-row-sync.store.ts @@ -0,0 +1,28 @@ +export interface ProjectDeviceLinkedRowValues { + linkedProjectDeviceId?: string; + name: string; + displayName: string; + phaseType?: string; + connectionKind?: string; + costGroup?: string; + category?: string; + level?: string; + roomId?: string; + roomNumberSnapshot?: string; + roomNameSnapshot?: string; + quantity: number; + powerPerUnit: number; + simultaneityFactor: number; + cosPhi?: number; + remark?: string; + overriddenFields?: string; +} + +export interface ProjectDeviceRowSyncStore { + updateLinkedRows( + projectDeviceId: string, + changes: Array<{ rowId: string; input: ProjectDeviceLinkedRowValues }> + ): void; + disconnectLinkedRows(projectDeviceId: string, rowIds: string[]): void; + reconnectRows(projectDeviceId: string, rowIds: string[]): void; +} diff --git a/src/domain/services/project-device-sync.service.ts b/src/domain/services/project-device-sync.service.ts index d27a612..5cf4e26 100644 --- a/src/domain/services/project-device-sync.service.ts +++ b/src/domain/services/project-device-sync.service.ts @@ -1,5 +1,6 @@ import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; +import type { ProjectDeviceRowSyncStore } from "../ports/project-device-row-sync.store.js"; import { projectDeviceSyncFields, type ProjectDeviceSyncField, @@ -14,10 +15,8 @@ type SyncDependencies = { CircuitDeviceRowRepository, | "listLinkedByProjectDevice" | "listLinkStatesByIds" - | "updateLinkedRowsTransactional" - | "disconnectLinkedRowsTransactional" - | "reconnectRowsTransactional" >; + deviceRowSyncStore: ProjectDeviceRowSyncStore; }; export interface ProjectDeviceSyncRestoreRow { @@ -71,10 +70,19 @@ function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) { export class ProjectDeviceSyncService { private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"]; private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"]; + private readonly deviceRowSyncStore?: SyncDependencies["deviceRowSyncStore"]; constructor(deps?: Partial) { this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository(); this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository(); + this.deviceRowSyncStore = deps?.deviceRowSyncStore; + } + + private getDeviceRowSyncStore() { + if (!this.deviceRowSyncStore) { + throw new Error("Project-device row synchronization is not configured."); + } + return this.deviceRowSyncStore; } async getPreview(projectId: string, projectDeviceId: string) { @@ -137,7 +145,7 @@ export class ProjectDeviceSyncService { next.overriddenFields = serializeOverriddenFields(remainingOverrides); changes.push({ rowId: row.id, input: next }); } - this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes); + this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes); return { preview: await this.getPreview(projectId, projectDeviceId), @@ -150,7 +158,10 @@ export class ProjectDeviceSyncService { const selectedRows = this.resolveSelectedRows(linkedRows, rowIds); const disconnectedRowIds = selectedRows.map((row) => row.id); - this.deviceRowRepository.disconnectLinkedRowsTransactional(projectDeviceId, disconnectedRowIds); + this.getDeviceRowSyncStore().disconnectLinkedRows( + projectDeviceId, + disconnectedRowIds + ); return { disconnectedRowIds, undo: { rowIds: disconnectedRowIds } }; } @@ -175,7 +186,7 @@ export class ProjectDeviceSyncService { return { rowId: row.id, input: next }; }); - this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes); + this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes); return this.getPreview(projectId, projectDeviceId); } @@ -189,7 +200,7 @@ export class ProjectDeviceSyncService { if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) { throw new Error("One or more rows cannot be reconnected."); } - this.deviceRowRepository.reconnectRowsTransactional(projectDeviceId, uniqueRowIds); + this.getDeviceRowSyncStore().reconnectRows(projectDeviceId, uniqueRowIds); return this.getPreview(projectId, projectDeviceId); } diff --git a/src/server/composition/project-device-sync-service.ts b/src/server/composition/project-device-sync-service.ts new file mode 100644 index 0000000..63ce51f --- /dev/null +++ b/src/server/composition/project-device-sync-service.ts @@ -0,0 +1,7 @@ +import { db } from "../../db/client.js"; +import { ProjectDeviceRowSyncRepository } from "../../db/repositories/project-device-row-sync.repository.js"; +import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js"; + +export const projectDeviceSyncService = new ProjectDeviceSyncService({ + deviceRowSyncStore: new ProjectDeviceRowSyncRepository(db), +}); diff --git a/src/server/controllers/project-device.controller.ts b/src/server/controllers/project-device.controller.ts index c2b2f6e..5a6b054 100644 --- a/src/server/controllers/project-device.controller.ts +++ b/src/server/controllers/project-device.controller.ts @@ -1,7 +1,7 @@ import type { Request, Response } from "express"; import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js"; import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; -import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js"; +import { projectDeviceSyncService } from "../composition/project-device-sync-service.js"; import { createProjectDeviceSchema, disconnectProjectDeviceRowsSchema, @@ -13,8 +13,6 @@ import { const globalDeviceRepository = new GlobalDeviceRepository(); const projectDeviceRepository = new ProjectDeviceRepository(); -const projectDeviceSyncService = new ProjectDeviceSyncService(); - export async function listProjectDevicesByProject(req: Request, res: Response) { const { projectId } = req.params; if (typeof projectId !== "string") { diff --git a/tests/project-device-row-sync.repository.test.ts b/tests/project-device-row-sync.repository.test.ts new file mode 100644 index 0000000..861238a --- /dev/null +++ b/tests/project-device-row-sync.repository.test.ts @@ -0,0 +1,269 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; +import { ProjectDeviceRowSyncRepository } from "../src/db/repositories/project-device-row-sync.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 { projectDevices } from "../src/db/schema/project-devices.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: "Beleuchtung", + sortOrder: 10, + isReserve: 0, + }) + .run(); + context.db + .insert(projectDevices) + .values({ + id: "project-device-1", + projectId: "project-1", + name: "Luminaire", + displayName: "Office lighting", + phaseType: "single_phase", + quantity: 4, + powerPerUnit: 0.04, + simultaneityFactor: 0.8, + installedPowerPerUnitKw: 0.04, + demandFactor: 0.8, + }) + .run(); + context.db + .insert(circuitDeviceRows) + .values([ + { + id: "row-1", + circuitId: "circuit-1", + linkedProjectDeviceId: "project-device-1", + sortOrder: 10, + name: "Old luminaire", + displayName: "Local row 1", + quantity: 1, + powerPerUnit: 0.03, + simultaneityFactor: 1, + }, + { + id: "row-2", + circuitId: "circuit-1", + linkedProjectDeviceId: "project-device-1", + sortOrder: 20, + name: "Old luminaire", + displayName: "Local row 2", + quantity: 2, + powerPerUnit: 0.03, + simultaneityFactor: 1, + }, + ]) + .run(); + return context; +} + +function synchronizedValues(displayName: string) { + return { + linkedProjectDeviceId: "project-device-1", + name: "Luminaire", + displayName, + phaseType: "single_phase", + quantity: 4, + powerPerUnit: 0.04, + simultaneityFactor: 0.8, + cosPhi: 0.95, + remark: "DALI", + }; +} + +function listRows(context: DatabaseContext) { + return context.db + .select() + .from(circuitDeviceRows) + .all() + .sort((left, right) => left.id.localeCompare(right.id)); +} + +describe("project-device row sync repository", () => { + it("commits synchronized values for all selected linked rows", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectDeviceRowSyncRepository(context.db); + + repository.updateLinkedRows("project-device-1", [ + { rowId: "row-1", input: synchronizedValues("Synced row 1") }, + { rowId: "row-2", input: synchronizedValues("Synced row 2") }, + ]); + + assert.deepEqual( + listRows(context).map((row) => [ + row.displayName, + row.quantity, + row.powerPerUnit, + row.simultaneityFactor, + ]), + [ + ["Synced row 1", 4, 0.04, 0.8], + ["Synced row 2", 4, 0.04, 0.8], + ] + ); + } finally { + context.close(); + } + }); + + it("rolls back earlier synchronized values when a later row update fails", () => { + const context = createTestDatabase(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_second_sync_update + BEFORE UPDATE ON circuit_device_rows + WHEN OLD.id = 'row-2' AND NEW.display_name = 'Synced' + BEGIN + SELECT RAISE(ABORT, 'forced sync failure'); + END; + `); + const repository = new ProjectDeviceRowSyncRepository(context.db); + + assert.throws( + () => + repository.updateLinkedRows("project-device-1", [ + { rowId: "row-1", input: synchronizedValues("Synced") }, + { rowId: "row-2", input: synchronizedValues("Synced") }, + ]), + /forced sync failure/ + ); + + assert.deepEqual( + listRows(context).map((row) => [row.displayName, row.quantity]), + [ + ["Local row 1", 1], + ["Local row 2", 2], + ] + ); + } finally { + context.close(); + } + }); + + it("disconnects all selected rows without changing local values", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectDeviceRowSyncRepository(context.db); + + repository.disconnectLinkedRows("project-device-1", ["row-1", "row-2"]); + + assert.deepEqual( + listRows(context).map((row) => [ + row.linkedProjectDeviceId, + row.displayName, + row.quantity, + ]), + [ + [null, "Local row 1", 1], + [null, "Local row 2", 2], + ] + ); + } finally { + context.close(); + } + }); + + it("rolls back earlier disconnects when a later row update fails", () => { + const context = createTestDatabase(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_second_disconnect + BEFORE UPDATE ON circuit_device_rows + WHEN OLD.id = 'row-2' AND NEW.linked_project_device_id IS NULL + BEGIN + SELECT RAISE(ABORT, 'forced disconnect failure'); + END; + `); + const repository = new ProjectDeviceRowSyncRepository(context.db); + + assert.throws( + () => + repository.disconnectLinkedRows("project-device-1", ["row-1", "row-2"]), + /forced disconnect failure/ + ); + + assert.deepEqual( + listRows(context).map((row) => row.linkedProjectDeviceId), + ["project-device-1", "project-device-1"] + ); + } finally { + context.close(); + } + }); + + it("reconnects all selected disconnected rows", () => { + const context = createTestDatabase(); + try { + context.db.update(circuitDeviceRows).set({ linkedProjectDeviceId: null }).run(); + const repository = new ProjectDeviceRowSyncRepository(context.db); + + repository.reconnectRows("project-device-1", ["row-1", "row-2"]); + + assert.deepEqual( + listRows(context).map((row) => row.linkedProjectDeviceId), + ["project-device-1", "project-device-1"] + ); + } finally { + context.close(); + } + }); + + it("rolls back earlier reconnects when a later row update fails", () => { + const context = createTestDatabase(); + try { + context.db.update(circuitDeviceRows).set({ linkedProjectDeviceId: null }).run(); + context.sqlite.exec(` + CREATE TRIGGER fail_second_reconnect + BEFORE UPDATE ON circuit_device_rows + WHEN OLD.id = 'row-2' AND NEW.linked_project_device_id = 'project-device-1' + BEGIN + SELECT RAISE(ABORT, 'forced reconnect failure'); + END; + `); + const repository = new ProjectDeviceRowSyncRepository(context.db); + + assert.throws( + () => repository.reconnectRows("project-device-1", ["row-1", "row-2"]), + /forced reconnect failure/ + ); + + assert.deepEqual( + listRows(context).map((row) => row.linkedProjectDeviceId), + [null, null] + ); + } finally { + context.close(); + } + }); +}); diff --git a/tests/project-device-sync.service.test.ts b/tests/project-device-sync.service.test.ts index 7f6d6bf..54c0c95 100644 --- a/tests/project-device-sync.service.test.ts +++ b/tests/project-device-sync.service.test.ts @@ -1,8 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { ProjectDeviceSyncService } from "../src/domain/services/project-device-sync.service.js"; -import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js"; -import { db } from "../src/db/client.js"; function projectDevice() { return { @@ -72,14 +70,16 @@ function createService() { async listLinkStatesByIds() { return rows.map((row) => ({ id: row.id, linkedProjectDeviceId: row.linkedProjectDeviceId })) as never; }, - updateLinkedRowsTransactional(_projectDeviceId, changes) { + } as never, + deviceRowSyncStore: { + updateLinkedRows(_projectDeviceId, changes) { transactionCalls += 1; for (const change of changes) { updates.push({ rowId: change.rowId, ...change.input }); Object.assign(rows.find((row) => row.id === change.rowId)!, change.input); } }, - disconnectLinkedRowsTransactional(_projectDeviceId, rowIds) { + disconnectLinkedRows(_projectDeviceId, rowIds) { transactionCalls += 1; for (const rowId of rowIds) { const row = rows.find((entry) => entry.id === rowId)!; @@ -87,7 +87,7 @@ function createService() { Object.assign(row, { linkedProjectDeviceId: null }); } }, - reconnectRowsTransactional(projectDeviceId, rowIds) { + reconnectRows(projectDeviceId, rowIds) { transactionCalls += 1; for (const rowId of rowIds) { Object.assign(rows.find((row) => row.id === rowId)!, { linkedProjectDeviceId: projectDeviceId }); @@ -160,54 +160,4 @@ describe("project device synchronization", () => { assert.equal(rows[0].linkedProjectDeviceId, "pd1"); }); - it("executes multi-row updates inside one synchronous transaction", () => { - const repository = new CircuitDeviceRowRepository(); - const originalTransaction = (db as unknown as { transaction: unknown }).transaction; - let transactionCalls = 0; - let updateCalls = 0; - let returnedPromise = false; - (db as unknown as { transaction: (callback: (tx: unknown) => unknown) => void }).transaction = (callback) => { - transactionCalls += 1; - const fakeTx = { - update() { - return { - set() { - return { - where() { - return { - run() { - updateCalls += 1; - return { changes: 1 }; - }, - }; - }, - }; - }, - }; - }, - }; - const result = callback(fakeTx); - returnedPromise = Boolean(result && typeof (result as Promise).then === "function"); - }; - - try { - const input = { - linkedProjectDeviceId: "pd1", - name: "Luminaire", - displayName: "Office lighting", - quantity: 1, - powerPerUnit: 0.1, - simultaneityFactor: 1, - }; - repository.updateLinkedRowsTransactional("pd1", [ - { rowId: "r1", input }, - { rowId: "r2", input }, - ]); - assert.equal(transactionCalls, 1); - assert.equal(updateCalls, 2); - assert.equal(returnedPromise, false); - } finally { - (db as unknown as { transaction: unknown }).transaction = originalTransaction; - } - }); });