Make initial circuit creation atomic

This commit is contained in:
2026-07-23 18:46:50 +02:00
parent 30c7555c68
commit a6837d7242
9 changed files with 234 additions and 169 deletions
+2 -1
View File
@@ -329,8 +329,9 @@ Implemented foundation:
- the distribution-board repository receives its database dependency explicitly - the distribution-board repository receives its database dependency explicitly
- distribution-board setup has real in-memory SQLite commit and rollback coverage using production migrations - 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 - 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
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks - circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
- persistence value mapping is separated from the general circuit-device-row repository - circuit and device-row persistence value mapping is separated from the general repositories
## Phase 12: Project Revisions and Persistent Undo / Redo ## Phase 12: Project Revisions and Persistent Undo / Redo
@@ -3,10 +3,12 @@ import { and, eq } from "drizzle-orm";
import type { import type {
CircuitDeviceRowTransactionStore, CircuitDeviceRowTransactionStore,
CreateCircuitDeviceRowTransactionInput, CreateCircuitDeviceRowTransactionInput,
CreateCircuitWithDeviceRowsTransactionInput,
} from "../../domain/ports/circuit-device-row-transaction.store.js"; } from "../../domain/ports/circuit-device-row-transaction.store.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { toCircuitCreateValues } from "./circuit.persistence.js";
import { import {
toCircuitDeviceRowCreateValues, toCircuitDeviceRowCreateValues,
} from "./circuit-device-row.persistence.js"; } from "./circuit-device-row.persistence.js";
@@ -53,6 +55,42 @@ export class CircuitDeviceRowTransactionRepository
return id; return id;
} }
createCircuitWithDeviceRows(input: CreateCircuitWithDeviceRowsTransactionInput) {
if (input.deviceRows.length === 0) {
throw new Error("Mindestens eine Gerätezeile ist erforderlich.");
}
const circuitId = crypto.randomUUID();
const rowIds = input.deviceRows.map(() => crypto.randomUUID());
this.database.transaction((tx) => {
tx
.insert(circuits)
.values(
toCircuitCreateValues(circuitId, {
...input.circuit,
isReserve: false,
})
)
.run();
for (let index = 0; index < input.deviceRows.length; index += 1) {
const row = input.deviceRows[index];
tx
.insert(circuitDeviceRows)
.values(
toCircuitDeviceRowCreateValues(rowIds[index], {
...row,
circuitId,
sortOrder: row.sortOrder ?? (index + 1) * 10,
})
)
.run();
}
});
return { circuitId, rowIds };
}
deleteFromCircuit(rowId: string, expectedCircuitId: string) { deleteFromCircuit(rowId: string, expectedCircuitId: string) {
this.database.transaction((tx) => { this.database.transaction((tx) => {
const [row] = tx const [row] = tx
@@ -13,11 +13,6 @@ import {
type CircuitDeviceRowPatchInput, type CircuitDeviceRowPatchInput,
type CircuitDeviceRowUpdateInput, type CircuitDeviceRowUpdateInput,
} from "./circuit-device-row.persistence.js"; } from "./circuit-device-row.persistence.js";
import {
toCircuitCreateValues,
type CircuitCreatePersistenceInput,
} from "./circuit.repository.js";
export type { export type {
CircuitDeviceRowCreateInput, CircuitDeviceRowCreateInput,
CircuitDeviceRowPatchInput, CircuitDeviceRowPatchInput,
@@ -37,13 +32,6 @@ export interface CircuitDeviceRowsBulkMoveInput {
}; };
} }
export interface CircuitWithDeviceRowsCreateInput {
circuit: CircuitCreatePersistenceInput;
deviceRows: Array<
Omit<CircuitDeviceRowCreateInput, "circuitId" | "sortOrder"> & { sortOrder?: number }
>;
}
export class CircuitDeviceRowRepository { export class CircuitDeviceRowRepository {
async findById(rowId: string) { async findById(rowId: string) {
const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1); const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1);
@@ -131,42 +119,6 @@ export class CircuitDeviceRowRepository {
return id; return id;
} }
createCircuitWithDeviceRowsTransactional(input: CircuitWithDeviceRowsCreateInput) {
if (input.deviceRows.length === 0) {
throw new Error("Mindestens eine Gerätezeile ist erforderlich.");
}
const circuitId = crypto.randomUUID();
const rowIds = input.deviceRows.map(() => crypto.randomUUID());
db.transaction((tx) => {
tx
.insert(circuits)
.values(
toCircuitCreateValues(circuitId, {
...input.circuit,
isReserve: false,
})
)
.run();
for (let index = 0; index < input.deviceRows.length; index += 1) {
const row = input.deviceRows[index];
tx
.insert(circuitDeviceRows)
.values(
toCircuitDeviceRowCreateValues(rowIds[index], {
...row,
circuitId,
sortOrder: row.sortOrder ?? (index + 1) * 10,
})
)
.run();
}
});
return { circuitId, rowIds };
}
async update( async update(
rowId: string, rowId: string,
input: CircuitDeviceRowUpdateInput input: CircuitDeviceRowUpdateInput
@@ -0,0 +1,44 @@
export interface CircuitCreatePersistenceInput {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
protectionType?: string;
protectionRatedCurrent?: number;
protectionCharacteristic?: string;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
voltage?: number;
controlRequirement?: string;
remark?: string;
rcdAssignment?: string;
terminalDesignation?: string;
status?: string;
isReserve?: boolean;
}
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
return {
id,
circuitListId: input.circuitListId,
sectionId: input.sectionId,
equipmentIdentifier: input.equipmentIdentifier,
displayName: input.displayName ?? null,
sortOrder: input.sortOrder,
protectionType: input.protectionType ?? null,
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
protectionCharacteristic: input.protectionCharacteristic ?? null,
cableType: input.cableType ?? null,
cableCrossSection: input.cableCrossSection ?? null,
cableLength: input.cableLength ?? null,
rcdAssignment: input.rcdAssignment ?? null,
terminalDesignation: input.terminalDesignation ?? null,
voltage: input.voltage ?? null,
controlRequirement: input.controlRequirement ?? null,
status: input.status ?? null,
isReserve: input.isReserve ? 1 : 0,
remark: input.remark ?? null,
};
}
+6 -44
View File
@@ -2,27 +2,13 @@ import crypto from "node:crypto";
import { and, asc, eq, inArray, ne } from "drizzle-orm"; import { and, asc, eq, inArray, ne } from "drizzle-orm";
import { db } from "../client.js"; import { db } from "../client.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import {
toCircuitCreateValues,
type CircuitCreatePersistenceInput,
} from "./circuit.persistence.js";
export interface CircuitCreatePersistenceInput { export type { CircuitCreatePersistenceInput } from "./circuit.persistence.js";
circuitListId: string; export { toCircuitCreateValues } from "./circuit.persistence.js";
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
protectionType?: string;
protectionRatedCurrent?: number;
protectionCharacteristic?: string;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
voltage?: number;
controlRequirement?: string;
remark?: string;
rcdAssignment?: string;
terminalDesignation?: string;
status?: string;
isReserve?: boolean;
}
export interface CircuitPatchPersistenceInput { export interface CircuitPatchPersistenceInput {
sectionId?: string; sectionId?: string;
@@ -44,30 +30,6 @@ export interface CircuitPatchPersistenceInput {
isReserve?: boolean; isReserve?: boolean;
} }
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
return {
id,
circuitListId: input.circuitListId,
sectionId: input.sectionId,
equipmentIdentifier: input.equipmentIdentifier,
displayName: input.displayName ?? null,
sortOrder: input.sortOrder,
protectionType: input.protectionType ?? null,
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
protectionCharacteristic: input.protectionCharacteristic ?? null,
cableType: input.cableType ?? null,
cableCrossSection: input.cableCrossSection ?? null,
cableLength: input.cableLength ?? null,
rcdAssignment: input.rcdAssignment ?? null,
terminalDesignation: input.terminalDesignation ?? null,
voltage: input.voltage ?? null,
controlRequirement: input.controlRequirement ?? null,
status: input.status ?? null,
isReserve: input.isReserve ? 1 : 0,
remark: input.remark ?? null,
};
}
function toCircuitPatchValues(input: CircuitPatchPersistenceInput) { function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
const values: Partial<typeof circuits.$inferInsert> = {}; const values: Partial<typeof circuits.$inferInsert> = {};
const has = (field: keyof CircuitPatchPersistenceInput) => const has = (field: keyof CircuitPatchPersistenceInput) =>
@@ -20,7 +20,35 @@ export interface CreateCircuitDeviceRowTransactionInput {
overriddenFields?: string; overriddenFields?: string;
} }
export interface CreateCircuitTransactionInput {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
protectionType?: string;
protectionRatedCurrent?: number;
protectionCharacteristic?: string;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
voltage?: number;
controlRequirement?: string;
remark?: string;
rcdAssignment?: string;
terminalDesignation?: string;
status?: string;
}
export interface CreateCircuitWithDeviceRowsTransactionInput {
circuit: CreateCircuitTransactionInput;
deviceRows: Array<Omit<CreateCircuitDeviceRowTransactionInput, "circuitId">>;
}
export interface CircuitDeviceRowTransactionStore { export interface CircuitDeviceRowTransactionStore {
createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string; createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string;
createCircuitWithDeviceRows(
input: CreateCircuitWithDeviceRowsTransactionInput
): { circuitId: string; rowIds: string[] };
deleteFromCircuit(rowId: string, expectedCircuitId: string): void; deleteFromCircuit(rowId: string, expectedCircuitId: string): void;
} }
+1 -2
View File
@@ -164,11 +164,10 @@ export class CircuitWriteService {
); );
} }
const created = this.deviceRowRepository.createCircuitWithDeviceRowsTransactional({ const created = this.getDeviceRowTransactionStore().createCircuitWithDeviceRows({
circuit: { circuit: {
circuitListId, circuitListId,
...input.circuit, ...input.circuit,
isReserve: false,
}, },
deviceRows: input.deviceRows, deviceRows: input.deviceRows,
}); });
@@ -134,6 +134,115 @@ describe("circuit device-row transaction repository", () => {
} }
}); });
it("commits a new circuit and all initial device rows together", () => {
const context = createTestDatabase();
try {
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
const repository = new CircuitDeviceRowTransactionRepository(context.db);
const created = repository.createCircuitWithDeviceRows({
circuit: {
circuitListId: seedCircuit.circuitListId,
sectionId: seedCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Beleuchtung",
sortOrder: 20,
},
deviceRows: [
{
name: "Leuchte",
displayName: "Leuchte 1",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
sortOrder: 15,
},
{
name: "Leuchte",
displayName: "Leuchte 2",
quantity: 2,
powerPerUnit: 0.1,
simultaneityFactor: 0.8,
},
],
});
const [createdCircuit] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, created.circuitId))
.all();
const createdRows = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, created.circuitId))
.all()
.sort((left, right) => left.sortOrder - right.sortOrder);
assert.equal(createdCircuit.equipmentIdentifier, "-1F2");
assert.equal(createdCircuit.isReserve, 0);
assert.deepEqual(
createdRows.map((row) => [row.id, row.displayName, row.sortOrder]),
[
[created.rowIds[0], "Leuchte 1", 15],
[created.rowIds[1], "Leuchte 2", 20],
]
);
} finally {
context.close();
}
});
it("rolls back the circuit and earlier rows when an initial row insert fails", () => {
const context = createTestDatabase();
try {
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
context.sqlite.exec(`
CREATE TRIGGER fail_initial_device_row
BEFORE INSERT ON circuit_device_rows
WHEN NEW.name = 'Fail'
BEGIN
SELECT RAISE(ABORT, 'forced initial row failure');
END;
`);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
assert.throws(
() =>
repository.createCircuitWithDeviceRows({
circuit: {
circuitListId: seedCircuit.circuitListId,
sectionId: seedCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Rollback",
sortOrder: 20,
},
deviceRows: [
{
name: "First",
displayName: "Erste Zeile",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
name: "Fail",
displayName: "Fehlerhafte Zeile",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
],
}),
/forced initial row failure/
);
assert.equal(context.db.select().from(circuits).all().length, 1);
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
} finally {
context.close();
}
});
it("commits the last-row deletion and activates the circuit reserve status together", () => { it("commits the last-row deletion and activates the circuit reserve status together", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
+6 -74
View File
@@ -251,14 +251,16 @@ describe("circuit write service rules", () => {
}, },
} as never, } as never,
deviceRowRepository: { deviceRowRepository: {
createCircuitWithDeviceRowsTransactional(input: typeof transactionalCreate) {
transactionalCreate = input;
return { circuitId: "c-new", rowIds: ["r1", "r2"] };
},
async findById(rowId: string) { async findById(rowId: string) {
return { id: rowId, circuitId: "c-new" } as never; return { id: rowId, circuitId: "c-new" } as never;
}, },
} as never, } as never,
deviceRowTransactionStore: {
createCircuitWithDeviceRows(input: typeof transactionalCreate) {
transactionalCreate = input;
return { circuitId: "c-new", rowIds: ["r1", "r2"] };
},
} as never,
}); });
const created = await service.createCircuitWithDeviceRows("p1", "l1", { const created = await service.createCircuitWithDeviceRows("p1", "l1", {
@@ -925,76 +927,6 @@ describe("circuit write service rules", () => {
} }
}); });
it("new circuit and initial device rows share one synchronous transaction", () => {
const repository = new CircuitDeviceRowRepository();
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
let callbackReturnedPromise = false;
const insertedValues: Array<Record<string, unknown>> = [];
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
const fakeTx = {
insert() {
return {
values(values: Record<string, unknown>) {
insertedValues.push(values);
return {
run() {
return { changes: 1 };
},
};
},
};
},
};
const callbackResult = cb(fakeTx);
callbackReturnedPromise = Boolean(
callbackResult && typeof (callbackResult as Promise<unknown>).then === "function"
);
};
try {
const created = repository.createCircuitWithDeviceRowsTransactional({
circuit: {
circuitListId: "l1",
sectionId: "s1",
equipmentIdentifier: "-2F1",
displayName: "Steckdosen",
sortOrder: 10,
},
deviceRows: [
{
name: "Socket",
displayName: "Steckdose",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
sortOrder: 15,
},
{
name: "Manual",
displayName: "Manuell",
quantity: 2,
powerPerUnit: 0.1,
simultaneityFactor: 0.8,
},
],
});
assert.equal(callbackReturnedPromise, false);
assert.equal(insertedValues.length, 3);
assert.equal(insertedValues[0].id, created.circuitId);
assert.equal(insertedValues[0].isReserve, 0);
assert.equal(insertedValues[1].id, created.rowIds[0]);
assert.equal(insertedValues[1].circuitId, created.circuitId);
assert.equal(insertedValues[1].sortOrder, 15);
assert.equal(insertedValues[2].id, created.rowIds[1]);
assert.equal(insertedValues[2].circuitId, created.circuitId);
assert.equal(insertedValues[2].sortOrder, 20);
} finally {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
it("tracks local edits on linked device rows as overridden fields", async () => { it("tracks local edits on linked device rows as overridden fields", async () => {
let updatePayload: Record<string, unknown> = {}; let updatePayload: Record<string, unknown> = {};
const current = { const current = {