Make device row moves atomic
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +45,31 @@ export interface CreateCircuitWithDeviceRowsTransactionInput {
|
||||
deviceRows: Array<Omit<CreateCircuitDeviceRowTransactionInput, "circuitId">>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<unknown>).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<string, unknown> = {};
|
||||
const current = {
|
||||
|
||||
Reference in New Issue
Block a user