diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index f7ff99a..92a1565 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -330,6 +330,7 @@ Implemented foundation: - distribution-board setup has real in-memory SQLite commit and rollback coverage using production migrations - 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 - circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks - circuit and device-row persistence value mapping is separated from the general repositories diff --git a/src/db/repositories/circuit-device-row-transaction.repository.ts b/src/db/repositories/circuit-device-row-transaction.repository.ts index 220b8c7..44aa8e2 100644 --- a/src/db/repositories/circuit-device-row-transaction.repository.ts +++ b/src/db/repositories/circuit-device-row-transaction.repository.ts @@ -1,9 +1,10 @@ import crypto from "node:crypto"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import type { CircuitDeviceRowTransactionStore, CreateCircuitDeviceRowTransactionInput, CreateCircuitWithDeviceRowsTransactionInput, + MoveCircuitDeviceRowsTransactionInput, } from "../../domain/ports/circuit-device-row-transaction.store.js"; import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; @@ -128,4 +129,154 @@ export class CircuitDeviceRowTransactionRepository .run(); }); } + + moveRows(input: MoveCircuitDeviceRowsTransactionInput) { + if (input.rows.length === 0) { + throw new Error("Keine Gerätezeilen zum Verschieben angegeben."); + } + if (Boolean(input.targetCircuitId) === Boolean(input.createTargetCircuit)) { + throw new Error("Für die Mehrfachverschiebung muss genau ein Ziel angegeben werden."); + } + + const rowIds = input.rows.map((row) => row.id); + if (new Set(rowIds).size !== rowIds.length) { + throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein."); + } + + const createdCircuitId = input.createTargetCircuit ? crypto.randomUUID() : undefined; + const targetCircuitId = input.targetCircuitId ?? createdCircuitId!; + + this.database.transaction((tx) => { + const persistedRows = tx + .select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId }) + .from(circuitDeviceRows) + .where(inArray(circuitDeviceRows.id, rowIds)) + .all(); + if (persistedRows.length !== input.rows.length) { + throw new Error("Eine oder mehrere Gerätezeilen sind ungültig."); + } + + const persistedRowsById = new Map(persistedRows.map((row) => [row.id, row])); + for (const expectedRow of input.rows) { + if ( + persistedRowsById.get(expectedRow.id)?.circuitId !== + expectedRow.expectedCircuitId + ) { + throw new Error( + "Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert." + ); + } + } + + let targetCircuit: { id: string; circuitListId: string }; + if (input.createTargetCircuit) { + tx + .insert(circuits) + .values({ + id: targetCircuitId, + circuitListId: input.createTargetCircuit.circuitListId, + sectionId: input.createTargetCircuit.sectionId, + equipmentIdentifier: input.createTargetCircuit.equipmentIdentifier, + displayName: input.createTargetCircuit.displayName, + sortOrder: input.createTargetCircuit.sortOrder, + isReserve: 0, + }) + .run(); + targetCircuit = { + id: targetCircuitId, + circuitListId: input.createTargetCircuit.circuitListId, + }; + } else { + const [persistedTargetCircuit] = tx + .select({ id: circuits.id, circuitListId: circuits.circuitListId }) + .from(circuits) + .where(eq(circuits.id, targetCircuitId)) + .limit(1) + .all(); + if (!persistedTargetCircuit) { + throw new Error("Der Zielstromkreis ist ungültig."); + } + targetCircuit = persistedTargetCircuit; + } + + const sourceCircuitIds = [ + ...new Set(input.rows.map((row) => row.expectedCircuitId)), + ]; + const sourceCircuits = tx + .select({ id: circuits.id, circuitListId: circuits.circuitListId }) + .from(circuits) + .where(inArray(circuits.id, sourceCircuitIds)) + .all(); + if (sourceCircuits.length !== sourceCircuitIds.length) { + throw new Error("Einer oder mehrere Quellstromkreise sind ungültig."); + } + if ( + sourceCircuits.some( + (circuit) => circuit.circuitListId !== targetCircuit.circuitListId + ) + ) { + throw new Error( + "Alle verschobenen Zeilen müssen zur Stromkreisliste des Ziels gehören." + ); + } + + const movedRowIdSet = new Set(rowIds); + const retainedTargetRows = tx + .select({ id: circuitDeviceRows.id, sortOrder: circuitDeviceRows.sortOrder }) + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, targetCircuitId)) + .all() + .filter((row) => !movedRowIdSet.has(row.id)); + const lastTargetSortOrder = retainedTargetRows.reduce( + (highest, row) => Math.max(highest, row.sortOrder), + 0 + ); + + for (let index = 0; index < input.rows.length; index += 1) { + const row = input.rows[index]; + const result = tx + .update(circuitDeviceRows) + .set({ + circuitId: targetCircuitId, + sortOrder: lastTargetSortOrder + (index + 1) * 10, + }) + .where( + and( + eq(circuitDeviceRows.id, row.id), + eq(circuitDeviceRows.circuitId, row.expectedCircuitId) + ) + ) + .run(); + if (result.changes !== 1) { + throw new Error( + "Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert." + ); + } + } + + for (const sourceCircuitId of sourceCircuitIds) { + const remainingRows = tx + .select({ id: circuitDeviceRows.id }) + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, sourceCircuitId)) + .all(); + tx + .update(circuits) + .set({ isReserve: remainingRows.length === 0 ? 1 : 0 }) + .where(eq(circuits.id, sourceCircuitId)) + .run(); + } + tx + .update(circuits) + .set({ isReserve: 0 }) + .where(eq(circuits.id, targetCircuitId)) + .run(); + }); + + return { + movedRowIds: rowIds, + targetCircuitId, + createdCircuitId, + }; + } } diff --git a/src/db/repositories/circuit-device-row.repository.ts b/src/db/repositories/circuit-device-row.repository.ts index a75408d..3ccc3d7 100644 --- a/src/db/repositories/circuit-device-row.repository.ts +++ b/src/db/repositories/circuit-device-row.repository.ts @@ -20,18 +20,6 @@ export type { } from "./circuit-device-row.persistence.js"; export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js"; -export interface CircuitDeviceRowsBulkMoveInput { - rows: Array<{ id: string; expectedCircuitId: string }>; - targetCircuitId?: string; - createTargetCircuit?: { - circuitListId: string; - sectionId: string; - equipmentIdentifier: string; - displayName: string; - sortOrder: number; - }; -} - export class CircuitDeviceRowRepository { async findById(rowId: string) { const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1); @@ -209,137 +197,4 @@ export class CircuitDeviceRowRepository { .where(eq(circuitDeviceRows.id, rowId)); } - moveRowsTransactional(input: CircuitDeviceRowsBulkMoveInput) { - if (input.rows.length === 0) { - throw new Error("Keine Gerätezeilen zum Verschieben angegeben."); - } - if (Boolean(input.targetCircuitId) === Boolean(input.createTargetCircuit)) { - throw new Error("Für die Mehrfachverschiebung muss genau ein Ziel angegeben werden."); - } - - const rowIds = input.rows.map((row) => row.id); - if (new Set(rowIds).size !== rowIds.length) { - throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein."); - } - - const createdCircuitId = input.createTargetCircuit ? crypto.randomUUID() : undefined; - const targetCircuitId = input.targetCircuitId ?? createdCircuitId!; - - // better-sqlite3 transactions must remain synchronous. Every row assignment, - // reserve-state update and optional target creation is committed or rolled back - // as one operation. - db.transaction((tx) => { - const persistedRows = tx - .select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId }) - .from(circuitDeviceRows) - .where(inArray(circuitDeviceRows.id, rowIds)) - .all(); - if (persistedRows.length !== input.rows.length) { - throw new Error("Eine oder mehrere Gerätezeilen sind ungültig."); - } - - const persistedRowsById = new Map(persistedRows.map((row) => [row.id, row])); - for (const expectedRow of input.rows) { - if (persistedRowsById.get(expectedRow.id)?.circuitId !== expectedRow.expectedCircuitId) { - throw new Error("Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."); - } - } - - let targetCircuit: { id: string; circuitListId: string }; - if (input.createTargetCircuit) { - tx - .insert(circuits) - .values({ - id: targetCircuitId, - circuitListId: input.createTargetCircuit.circuitListId, - sectionId: input.createTargetCircuit.sectionId, - equipmentIdentifier: input.createTargetCircuit.equipmentIdentifier, - displayName: input.createTargetCircuit.displayName, - sortOrder: input.createTargetCircuit.sortOrder, - isReserve: 0, - }) - .run(); - targetCircuit = { - id: targetCircuitId, - circuitListId: input.createTargetCircuit.circuitListId, - }; - } else { - const [persistedTargetCircuit] = tx - .select({ id: circuits.id, circuitListId: circuits.circuitListId }) - .from(circuits) - .where(eq(circuits.id, targetCircuitId)) - .limit(1) - .all(); - if (!persistedTargetCircuit) { - throw new Error("Der Zielstromkreis ist ungültig."); - } - targetCircuit = persistedTargetCircuit; - } - - const sourceCircuitIds = [...new Set(input.rows.map((row) => row.expectedCircuitId))]; - const sourceCircuits = tx - .select({ id: circuits.id, circuitListId: circuits.circuitListId }) - .from(circuits) - .where(inArray(circuits.id, sourceCircuitIds)) - .all(); - if (sourceCircuits.length !== sourceCircuitIds.length) { - throw new Error("Einer oder mehrere Quellstromkreise sind ungültig."); - } - if (sourceCircuits.some((circuit) => circuit.circuitListId !== targetCircuit.circuitListId)) { - throw new Error("Alle verschobenen Zeilen müssen zur Stromkreisliste des Ziels gehören."); - } - - const movedRowIdSet = new Set(rowIds); - const retainedTargetRows = tx - .select({ id: circuitDeviceRows.id, sortOrder: circuitDeviceRows.sortOrder }) - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.circuitId, targetCircuitId)) - .all() - .filter((row) => !movedRowIdSet.has(row.id)); - const lastTargetSortOrder = retainedTargetRows.reduce( - (highest, row) => Math.max(highest, row.sortOrder), - 0 - ); - - for (let index = 0; index < input.rows.length; index += 1) { - const row = input.rows[index]; - const result = tx - .update(circuitDeviceRows) - .set({ - circuitId: targetCircuitId, - sortOrder: lastTargetSortOrder + (index + 1) * 10, - }) - .where( - and( - eq(circuitDeviceRows.id, row.id), - eq(circuitDeviceRows.circuitId, row.expectedCircuitId) - ) - ) - .run(); - if (result.changes !== 1) { - throw new Error("Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."); - } - } - - for (const sourceCircuitId of sourceCircuitIds) { - const remainingRows = tx - .select({ id: circuitDeviceRows.id }) - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.circuitId, sourceCircuitId)) - .all(); - tx - .update(circuits) - .set({ isReserve: remainingRows.length === 0 ? 1 : 0 }) - .where(eq(circuits.id, sourceCircuitId)) - .run(); - } - tx.update(circuits).set({ isReserve: 0 }).where(eq(circuits.id, targetCircuitId)).run(); - }); - - return { - movedRowIds: rowIds, - targetCircuitId, - createdCircuitId, - }; - } } diff --git a/src/domain/ports/circuit-device-row-transaction.store.ts b/src/domain/ports/circuit-device-row-transaction.store.ts index 0cca4b8..25440a8 100644 --- a/src/domain/ports/circuit-device-row-transaction.store.ts +++ b/src/domain/ports/circuit-device-row-transaction.store.ts @@ -45,10 +45,31 @@ export interface CreateCircuitWithDeviceRowsTransactionInput { deviceRows: Array>; } +export interface MoveCircuitDeviceRowsTransactionInput { + rows: Array<{ id: string; expectedCircuitId: string }>; + targetCircuitId?: string; + createTargetCircuit?: { + circuitListId: string; + sectionId: string; + equipmentIdentifier: string; + displayName: string; + sortOrder: number; + }; +} + +export interface MoveCircuitDeviceRowsTransactionResult { + movedRowIds: string[]; + targetCircuitId: string; + createdCircuitId?: string; +} + export interface CircuitDeviceRowTransactionStore { createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string; createCircuitWithDeviceRows( input: CreateCircuitWithDeviceRowsTransactionInput ): { circuitId: string; rowIds: string[] }; deleteFromCircuit(rowId: string, expectedCircuitId: string): void; + moveRows( + input: MoveCircuitDeviceRowsTransactionInput + ): MoveCircuitDeviceRowsTransactionResult; } diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index fed6166..dd27fa4 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -334,7 +334,7 @@ export class CircuitWriteService { return this.deviceRowRepository.findById(rowId); } - this.deviceRowRepository.moveRowsTransactional({ + this.getDeviceRowTransactionStore().moveRows({ rows: [{ id: row.id, expectedCircuitId: row.circuitId }], targetCircuitId: targetCircuit?.id, createTargetCircuit, @@ -415,7 +415,7 @@ export class CircuitWriteService { } } - return this.deviceRowRepository.moveRowsTransactional({ + return this.getDeviceRowTransactionStore().moveRows({ rows: rows.map((row) => ({ id: row.id, expectedCircuitId: row.circuitId })), targetCircuitId: targetCircuit?.id, createTargetCircuit, diff --git a/tests/circuit-device-row-transaction.repository.test.ts b/tests/circuit-device-row-transaction.repository.test.ts index a4864e5..e317347 100644 --- a/tests/circuit-device-row-transaction.repository.test.ts +++ b/tests/circuit-device-row-transaction.repository.test.ts @@ -47,15 +47,49 @@ function createTestDatabase(): DatabaseContext { return context; } -function insertDeviceRow(context: DatabaseContext) { +function insertCircuit( + context: DatabaseContext, + input: { + id: string; + equipmentIdentifier: string; + sortOrder: number; + isReserve?: number; + } +) { + const [seedCircuit] = context.db.select().from(circuits).limit(1).all(); + context.db + .insert(circuits) + .values({ + id: input.id, + circuitListId: seedCircuit.circuitListId, + sectionId: seedCircuit.sectionId, + equipmentIdentifier: input.equipmentIdentifier, + displayName: input.equipmentIdentifier, + sortOrder: input.sortOrder, + isReserve: input.isReserve ?? 1, + }) + .run(); +} + +function insertDeviceRow( + context: DatabaseContext, + input: { + id?: string; + circuitId?: string; + sortOrder?: number; + displayName?: string; + } = {} +) { + const rowId = input.id ?? "row-1"; + const circuitId = input.circuitId ?? "circuit-1"; context.db .insert(circuitDeviceRows) .values({ - id: "row-1", - circuitId: "circuit-1", - sortOrder: 10, + id: rowId, + circuitId, + sortOrder: input.sortOrder ?? 10, name: "Leuchte", - displayName: "Leuchte", + displayName: input.displayName ?? "Leuchte", quantity: 1, powerPerUnit: 0.1, simultaneityFactor: 1, @@ -64,7 +98,7 @@ function insertDeviceRow(context: DatabaseContext) { context.db .update(circuits) .set({ isReserve: 0 }) - .where(eq(circuits.id, "circuit-1")) + .where(eq(circuits.id, circuitId)) .run(); } @@ -283,4 +317,213 @@ describe("circuit device-row transaction repository", () => { context.close(); } }); + + it("moves rows to an existing circuit and updates both reserve states together", () => { + const context = createTestDatabase(); + try { + insertCircuit(context, { + id: "circuit-2", + equipmentIdentifier: "-1F2", + sortOrder: 20, + }); + insertCircuit(context, { + id: "circuit-3", + equipmentIdentifier: "-1F3", + sortOrder: 30, + }); + insertDeviceRow(context); + insertDeviceRow(context, { + id: "row-2", + circuitId: "circuit-2", + sortOrder: 10, + displayName: "Zweite Quellzeile", + }); + insertDeviceRow(context, { + id: "target-row", + circuitId: "circuit-3", + sortOrder: 10, + displayName: "Bestehende Zielzeile", + }); + const repository = new CircuitDeviceRowTransactionRepository(context.db); + + const result = repository.moveRows({ + rows: [ + { id: "row-1", expectedCircuitId: "circuit-1" }, + { id: "row-2", expectedCircuitId: "circuit-2" }, + ], + targetCircuitId: "circuit-3", + }); + + const movedRows = context.db + .select() + .from(circuitDeviceRows) + .all() + .filter((row) => row.id === "row-1" || row.id === "row-2") + .sort((left, right) => left.sortOrder - right.sortOrder); + const persistedCircuits = context.db.select().from(circuits).all(); + const targetCircuit = persistedCircuits.find( + (circuit) => circuit.id === "circuit-3" + ); + + assert.deepEqual(result, { + movedRowIds: ["row-1", "row-2"], + targetCircuitId: "circuit-3", + createdCircuitId: undefined, + }); + assert.deepEqual( + movedRows.map((row) => [row.id, row.circuitId, row.sortOrder]), + [ + ["row-1", "circuit-3", 20], + ["row-2", "circuit-3", 30], + ] + ); + assert.equal( + persistedCircuits.find((circuit) => circuit.id === "circuit-1")?.isReserve, + 1 + ); + assert.equal( + persistedCircuits.find((circuit) => circuit.id === "circuit-2")?.isReserve, + 1 + ); + assert.equal(targetCircuit?.isReserve, 0); + } finally { + context.close(); + } + }); + + it("rolls back an existing-target move when a reserve update fails", () => { + const context = createTestDatabase(); + try { + insertCircuit(context, { + id: "circuit-2", + equipmentIdentifier: "-1F2", + sortOrder: 20, + }); + insertDeviceRow(context); + context.sqlite.exec(` + CREATE TRIGGER fail_source_reserve_update + BEFORE UPDATE OF is_reserve ON circuits + WHEN NEW.id = 'circuit-1' AND NEW.is_reserve = 1 + BEGIN + SELECT RAISE(ABORT, 'forced source reserve failure'); + END; + `); + const repository = new CircuitDeviceRowTransactionRepository(context.db); + + assert.throws( + () => + repository.moveRows({ + rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }], + targetCircuitId: "circuit-2", + }), + /forced source reserve failure/ + ); + + const [row] = context.db.select().from(circuitDeviceRows).all(); + const persistedCircuits = context.db.select().from(circuits).all(); + assert.equal(row.circuitId, "circuit-1"); + assert.equal( + persistedCircuits.find((circuit) => circuit.id === "circuit-1")?.isReserve, + 0 + ); + assert.equal( + persistedCircuits.find((circuit) => circuit.id === "circuit-2")?.isReserve, + 1 + ); + } finally { + context.close(); + } + }); + + it("creates a new target circuit and moves rows into it atomically", () => { + const context = createTestDatabase(); + try { + insertDeviceRow(context); + const [sourceCircuit] = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .all(); + const repository = new CircuitDeviceRowTransactionRepository(context.db); + + const result = repository.moveRows({ + rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }], + createTargetCircuit: { + circuitListId: sourceCircuit.circuitListId, + sectionId: sourceCircuit.sectionId, + equipmentIdentifier: "-1F2", + displayName: "Neues Ziel", + sortOrder: 20, + }, + }); + + const [movedRow] = context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .all(); + const [createdCircuit] = context.db + .select() + .from(circuits) + .where(eq(circuits.id, result.createdCircuitId!)) + .all(); + const [updatedSource] = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .all(); + + assert.equal(result.targetCircuitId, result.createdCircuitId); + assert.equal(createdCircuit.equipmentIdentifier, "-1F2"); + assert.equal(createdCircuit.isReserve, 0); + assert.equal(movedRow.circuitId, createdCircuit.id); + assert.equal(updatedSource.isReserve, 1); + } finally { + context.close(); + } + }); + + it("rolls back a newly created target circuit when the move fails", () => { + const context = createTestDatabase(); + try { + insertDeviceRow(context); + const [sourceCircuit] = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .all(); + context.sqlite.exec(` + CREATE TRIGGER fail_new_target_source_reserve + BEFORE UPDATE OF is_reserve ON circuits + WHEN NEW.id = 'circuit-1' AND NEW.is_reserve = 1 + BEGIN + SELECT RAISE(ABORT, 'forced new-target move failure'); + END; + `); + const repository = new CircuitDeviceRowTransactionRepository(context.db); + + assert.throws( + () => + repository.moveRows({ + rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }], + createTargetCircuit: { + circuitListId: sourceCircuit.circuitListId, + sectionId: sourceCircuit.sectionId, + equipmentIdentifier: "-1F2", + displayName: "Rollback-Ziel", + sortOrder: 20, + }, + }), + /forced new-target move failure/ + ); + + const [row] = context.db.select().from(circuitDeviceRows).all(); + const persistedCircuits = context.db.select().from(circuits).all(); + assert.equal(row.circuitId, "circuit-1"); + assert.equal(persistedCircuits.length, 1); + assert.equal(persistedCircuits[0].isReserve, 0); + } finally { + context.close(); + } + }); }); diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts index d09557f..1656b86 100644 --- a/tests/circuit-write.rules.test.ts +++ b/tests/circuit-write.rules.test.ts @@ -414,7 +414,9 @@ describe("circuit write service rules", () => { async findById() { return { id: "r1", circuitId: "c1" } as never; }, - moveRowsTransactional(input: typeof transactionalMove) { + } as never, + deviceRowTransactionStore: { + moveRows(input: typeof transactionalMove) { transactionalMove = input; return { movedRowIds: input!.rows.map((row) => row.id), @@ -469,7 +471,9 @@ describe("circuit write service rules", () => { async findById() { return { id: "r1", circuitId: "c1" } as never; }, - moveRowsTransactional(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) { + } as never, + deviceRowTransactionStore: { + moveRows(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) { preparedTarget = input.createTargetCircuit; return { movedRowIds: input.rows.map((row) => row.id), @@ -525,7 +529,9 @@ describe("circuit write service rules", () => { async findById() { return { id: "r1", circuitId: "c1" } as never; }, - moveRowsTransactional() { + } as never, + deviceRowTransactionStore: { + moveRows() { transactionalMoveCount += 1; throw new Error("same-circuit move must not write"); }, @@ -565,7 +571,9 @@ describe("circuit write service rules", () => { } return { id: "r2", circuitId: "c2" } as never; }, - moveRowsTransactional(input: typeof transactionalMove) { + } as never, + deviceRowTransactionStore: { + moveRows(input: typeof transactionalMove) { transactionalMove = input; return { movedRowIds: input!.rows.map((row) => row.id), @@ -617,7 +625,9 @@ describe("circuit write service rules", () => { async findById(rowId: string) { return { id: rowId, circuitId: rowId === "r1" ? "c1" : "c2" } as never; }, - moveRowsTransactional(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) { + } as never, + deviceRowTransactionStore: { + moveRows(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) { transactionCount += 1; preparedTarget = input.createTargetCircuit; return { @@ -839,94 +849,6 @@ describe("circuit write service rules", () => { } }); - it("bulk device row move uses one synchronous transaction callback", () => { - const repository = new CircuitDeviceRowRepository(); - const originalTransaction = (db as unknown as { transaction: unknown }).transaction; - - let callbackReturnedPromise = false; - let selectCall = 0; - let rowUpdateCount = 0; - const selectedRows = [ - [ - { id: "r1", circuitId: "c1" }, - { id: "r2", circuitId: "c2" }, - ], - [{ id: "c3", circuitListId: "l1" }], - [ - { id: "c1", circuitListId: "l1" }, - { id: "c2", circuitListId: "l1" }, - ], - [{ id: "existing-target-row", sortOrder: 10 }], - [], - [], - ]; - - (db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => { - const fakeTx = { - select() { - const rows = selectedRows[selectCall++] ?? []; - const query = { - from() { - return query; - }, - where() { - return query; - }, - limit() { - return query; - }, - all() { - return rows; - }, - }; - return query; - }, - update() { - return { - set() { - return { - where() { - return { - run() { - rowUpdateCount += 1; - return { changes: 1 }; - }, - }; - }, - }; - }, - }; - }, - }; - const callbackResult = cb(fakeTx); - callbackReturnedPromise = Boolean( - callbackResult && typeof (callbackResult as Promise).then === "function" - ); - if (callbackReturnedPromise) { - throw new Error("Transaction function cannot return a promise"); - } - }; - - try { - const result = repository.moveRowsTransactional({ - rows: [ - { id: "r1", expectedCircuitId: "c1" }, - { id: "r2", expectedCircuitId: "c2" }, - ], - targetCircuitId: "c3", - }); - assert.equal(callbackReturnedPromise, false); - assert.equal(rowUpdateCount, 5); - assert.deepEqual(result, { - movedRowIds: ["r1", "r2"], - targetCircuitId: "c3", - createdCircuitId: undefined, - }); - } 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 = {