Add atomic circuit update commands

This commit is contained in:
2026-07-23 21:32:25 +02:00
parent b79faed320
commit 296cb0f1c4
17 changed files with 940 additions and 167 deletions
+6 -2
View File
@@ -68,8 +68,12 @@ Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und
bestehende Fachoperationen sind aber noch nicht an diese Grenze angeschlossen; bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze
serverseitiges Undo/Redo ist daher noch nicht verfügbar. angeschlossen. Für Circuit-Feldänderungen existiert ein erster interner
Command-Store, der Fachänderung, automatisch erzeugtes inverses Kommando und
Revision gemeinsam committen beziehungsweise zurückrollen kann. Er ist noch
nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo ist daher
noch nicht verfügbar.
## Projektgeräte ## Projektgeräte
@@ -149,11 +149,18 @@ Completed foundation:
history records atomically history records atomically
- revision appends use an expected-revision compare-and-set and reject stale - revision appends use an expected-revision compare-and-set and reject stale
callers without writing partial history callers without writing partial history
- a first internal `circuit.update` command validates project/list/section
ownership and equipment-identifier uniqueness inside its transaction
- circuit updates derive their inverse from the persisted pre-command row and
commit the domain update, inverse command, change set and revision together
- integration tests apply the generated inverse as a new `undo` revision and
verify rollback for stale revisions and late history failures
Remaining constraints before implementing history: Remaining constraints before implementing history:
- define concrete project-scoped command unions and executors independently - extend the concrete project-scoped command union and executors beyond the
from React callbacks; the generic versioned command envelope is complete internal `circuit.update` slice; the generic versioned command envelope is
complete
- route domain writes and revision recording through one shared project - route domain writes and revision recording through one shared project
transaction boundary; the current revision repository only commits history transaction boundary; the current revision repository only commits history
records and the counter together records and the counter together
@@ -372,6 +372,10 @@ Implemented foundation:
- each revision has one separately stored logical change set - each revision has one separately stored logical change set
- forward and inverse changes use complete, versioned JSON command envelopes - forward and inverse changes use complete, versioned JSON command envelopes
- invalid or lossy command payloads are rejected before persistence - invalid or lossy command payloads are rejected before persistence
- an internal typed `circuit.update` command generates its inverse from the
persisted pre-command state
- circuit update, inverse command, change set and revision share one SQLite
transaction with stale-command and late-failure rollback coverage
- revision metadata, change-set payloads and the project counter are committed - revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction in one SQLite transaction
- a stale expected revision produces no history writes - a stale expected revision produces no history writes
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/database-backup.test.ts", "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/database-backup.test.ts", "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/database-backup.test.ts",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
@@ -0,0 +1,163 @@
import { and, eq, ne } from "drizzle-orm";
import {
assertCircuitUpdateProjectCommand,
createCircuitUpdateProjectCommand,
type CircuitUpdateField,
type CircuitUpdatePatch,
type CircuitUpdateValues,
} from "../../domain/models/circuit-project-command.model.js";
import type {
CircuitProjectCommandStore,
ExecuteCircuitUpdateCommandInput,
} from "../../domain/ports/circuit-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import {
toCircuitPatchValues,
type CircuitPatchPersistenceInput,
} from "./circuit.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitRow = typeof circuits.$inferSelect;
export class CircuitProjectCommandRepository
implements CircuitProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
executeUpdate(input: ExecuteCircuitUpdateCommandInput) {
assertCircuitUpdateProjectCommand(input.command);
return this.database.transaction((tx) => {
const current = tx
.select()
.from(circuits)
.where(eq(circuits.id, input.command.payload.circuitId))
.get();
if (!current) {
throw new Error("Invalid circuit id.");
}
const owningList = tx
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, current.circuitListId),
eq(circuitLists.projectId, input.projectId)
)
)
.get();
if (!owningList) {
throw new Error("Circuit does not belong to project.");
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as CircuitPatchPersistenceInput;
this.assertSectionInCircuitList(tx, current, patch.sectionId);
this.assertUniqueEquipmentIdentifier(
tx,
current,
patch.equipmentIdentifier
);
const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
getCircuitFieldValue(current, change.field),
])
) as CircuitUpdatePatch;
const inverse = createCircuitUpdateProjectCommand(
current.id,
inversePatch
);
const update = tx
.update(circuits)
.set(toCircuitPatchValues(patch))
.where(eq(circuits.id, current.id))
.run();
if (update.changes !== 1) {
throw new Error("Circuit changed before command execution.");
}
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
return { revision, inverse };
});
}
private assertSectionInCircuitList(
database: AppDatabase,
circuit: CircuitRow,
sectionId: string | undefined
) {
if (sectionId === undefined || sectionId === circuit.sectionId) {
return;
}
const section = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(
and(
eq(circuitSections.id, sectionId),
eq(circuitSections.circuitListId, circuit.circuitListId)
)
)
.get();
if (!section) {
throw new Error("Section does not belong to circuit list.");
}
}
private assertUniqueEquipmentIdentifier(
database: AppDatabase,
circuit: CircuitRow,
equipmentIdentifier: string | undefined
) {
if (
equipmentIdentifier === undefined ||
equipmentIdentifier === circuit.equipmentIdentifier
) {
return;
}
const duplicate = database
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, circuit.circuitListId),
eq(circuits.equipmentIdentifier, equipmentIdentifier),
ne(circuits.id, circuit.id)
)
)
.get();
if (duplicate) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
}
}
function getCircuitFieldValue<TField extends CircuitUpdateField>(
circuit: CircuitRow,
field: TField
): CircuitUpdateValues[TField] {
if (field === "isReserve") {
return Boolean(circuit.isReserve) as CircuitUpdateValues[TField];
}
return circuit[field] as unknown as CircuitUpdateValues[TField];
}
@@ -19,6 +19,26 @@ export interface CircuitCreatePersistenceInput {
isReserve?: boolean; isReserve?: boolean;
} }
export interface CircuitPatchPersistenceInput {
sectionId?: string;
equipmentIdentifier?: string;
displayName?: string | null;
sortOrder?: number;
protectionType?: string | null;
protectionRatedCurrent?: number | null;
protectionCharacteristic?: string | null;
cableType?: string | null;
cableCrossSection?: string | null;
cableLength?: number | null;
voltage?: number | null;
controlRequirement?: string | null;
remark?: string | null;
rcdAssignment?: string | null;
terminalDesignation?: string | null;
status?: string | null;
isReserve?: boolean;
}
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) { export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
return { return {
id, id,
@@ -42,3 +62,41 @@ export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenc
remark: input.remark ?? null, remark: input.remark ?? null,
}; };
} }
export function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
const values: Partial<ReturnType<typeof toCircuitCreateValues>> = {};
const has = (field: keyof CircuitPatchPersistenceInput) =>
Object.prototype.hasOwnProperty.call(input, field);
if (input.sectionId !== undefined) values.sectionId = input.sectionId;
if (input.equipmentIdentifier !== undefined) {
values.equipmentIdentifier = input.equipmentIdentifier;
}
if (has("displayName")) values.displayName = input.displayName ?? null;
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
if (has("protectionType")) values.protectionType = input.protectionType ?? null;
if (has("protectionRatedCurrent")) {
values.protectionRatedCurrent = input.protectionRatedCurrent ?? null;
}
if (has("protectionCharacteristic")) {
values.protectionCharacteristic = input.protectionCharacteristic ?? null;
}
if (has("cableType")) values.cableType = input.cableType ?? null;
if (has("cableCrossSection")) {
values.cableCrossSection = input.cableCrossSection ?? null;
}
if (has("cableLength")) values.cableLength = input.cableLength ?? null;
if (has("rcdAssignment")) values.rcdAssignment = input.rcdAssignment ?? null;
if (has("terminalDesignation")) {
values.terminalDesignation = input.terminalDesignation ?? null;
}
if (has("voltage")) values.voltage = input.voltage ?? null;
if (has("controlRequirement")) {
values.controlRequirement = input.controlRequirement ?? null;
}
if (has("status")) values.status = input.status ?? null;
if (input.isReserve !== undefined) values.isReserve = input.isReserve ? 1 : 0;
if (has("remark")) values.remark = input.remark ?? null;
return values;
}
+10 -58
View File
@@ -4,67 +4,19 @@ import { db } from "../client.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { import {
toCircuitCreateValues, toCircuitCreateValues,
toCircuitPatchValues,
type CircuitCreatePersistenceInput, type CircuitCreatePersistenceInput,
type CircuitPatchPersistenceInput,
} from "./circuit.persistence.js"; } from "./circuit.persistence.js";
export type { CircuitCreatePersistenceInput } from "./circuit.persistence.js"; export type {
export { toCircuitCreateValues } from "./circuit.persistence.js"; CircuitCreatePersistenceInput,
CircuitPatchPersistenceInput,
export interface CircuitPatchPersistenceInput { } from "./circuit.persistence.js";
sectionId?: string; export {
equipmentIdentifier?: string; toCircuitCreateValues,
displayName?: string; toCircuitPatchValues,
sortOrder?: number; } from "./circuit.persistence.js";
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;
}
function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
const values: Partial<typeof circuits.$inferInsert> = {};
const has = (field: keyof CircuitPatchPersistenceInput) =>
Object.prototype.hasOwnProperty.call(input, field);
if (input.sectionId !== undefined) values.sectionId = input.sectionId;
if (input.equipmentIdentifier !== undefined) {
values.equipmentIdentifier = input.equipmentIdentifier;
}
if (has("displayName")) values.displayName = input.displayName ?? null;
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
if (has("protectionType")) values.protectionType = input.protectionType ?? null;
if (has("protectionRatedCurrent")) {
values.protectionRatedCurrent = input.protectionRatedCurrent ?? null;
}
if (has("protectionCharacteristic")) {
values.protectionCharacteristic = input.protectionCharacteristic ?? null;
}
if (has("cableType")) values.cableType = input.cableType ?? null;
if (has("cableCrossSection")) values.cableCrossSection = input.cableCrossSection ?? null;
if (has("cableLength")) values.cableLength = input.cableLength ?? null;
if (has("rcdAssignment")) values.rcdAssignment = input.rcdAssignment ?? null;
if (has("terminalDesignation")) {
values.terminalDesignation = input.terminalDesignation ?? null;
}
if (has("voltage")) values.voltage = input.voltage ?? null;
if (has("controlRequirement")) {
values.controlRequirement = input.controlRequirement ?? null;
}
if (has("status")) values.status = input.status ?? null;
if (input.isReserve !== undefined) values.isReserve = input.isReserve ? 1 : 0;
if (has("remark")) values.remark = input.remark ?? null;
return values;
}
export class CircuitRepository { export class CircuitRepository {
async findById(circuitId: string) { async findById(circuitId: string) {
@@ -0,0 +1,94 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { serializeProjectCommand } from "../../domain/models/project-command.model.js";
import type {
AppendProjectRevisionInput,
AppendedProjectRevision,
} from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projectRevisions } from "../schema/project-revisions.js";
import { projects } from "../schema/projects.js";
export function appendProjectRevision(
database: AppDatabase,
input: AppendProjectRevisionInput
): AppendedProjectRevision {
validateInput(input);
const forwardPayloadJson = serializeProjectCommand(input.forward);
const inversePayloadJson = serializeProjectCommand(input.inverse);
const revisionId = crypto.randomUUID();
const changeSetId = crypto.randomUUID();
const revisionNumber = input.expectedRevision + 1;
const createdAtIso = new Date().toISOString();
const revisionUpdate = database
.update(projects)
.set({ currentRevision: revisionNumber })
.where(
and(
eq(projects.id, input.projectId),
eq(projects.currentRevision, input.expectedRevision)
)
)
.run();
if (revisionUpdate.changes !== 1) {
const current = database
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
throw new ProjectRevisionConflictError(
input.projectId,
input.expectedRevision,
current?.currentRevision ?? null
);
}
database
.insert(projectRevisions)
.values({
id: revisionId,
projectId: input.projectId,
revisionNumber,
createdAtIso,
actorId: input.actorId,
source: input.source,
description: input.description,
})
.run();
database
.insert(projectChangeSets)
.values({
id: changeSetId,
projectRevisionId: revisionId,
commandType: input.forward.type,
payloadSchemaVersion: input.forward.schemaVersion,
forwardPayloadJson,
inversePayloadJson,
})
.run();
return {
revisionId,
changeSetId,
projectId: input.projectId,
revisionNumber,
createdAtIso,
};
}
function validateInput(input: AppendProjectRevisionInput) {
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) {
throw new Error("Expected project revision must be a non-negative integer.");
}
if (input.forward.schemaVersion !== input.inverse.schemaVersion) {
throw new Error(
"Forward and inverse project commands require the same schema version."
);
}
}
@@ -1,30 +1,15 @@
import crypto from "node:crypto"; import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm"; import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { serializeProjectCommand } from "../../domain/models/project-command.model.js";
import type { import type {
AppendProjectRevisionInput, AppendProjectRevisionInput,
AppendedProjectRevision, AppendedProjectRevision,
ProjectRevisionStore, ProjectRevisionStore,
} from "../../domain/ports/project-revision.store.js"; } from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projectRevisions } from "../schema/project-revisions.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class ProjectRevisionConflictError extends Error { export { ProjectRevisionConflictError };
constructor(
readonly projectId: string,
readonly expectedRevision: number,
readonly actualRevision: number | null
) {
super(
actualRevision === null
? `Project ${projectId} does not exist.`
: `Project ${projectId} is at revision ${actualRevision}, expected ${expectedRevision}.`
);
this.name = "ProjectRevisionConflictError";
}
}
export class ProjectRevisionRepository implements ProjectRevisionStore { export class ProjectRevisionRepository implements ProjectRevisionStore {
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
@@ -39,83 +24,8 @@ export class ProjectRevisionRepository implements ProjectRevisionStore {
} }
append(input: AppendProjectRevisionInput): AppendedProjectRevision { append(input: AppendProjectRevisionInput): AppendedProjectRevision {
this.validateInput(input); return this.database.transaction((tx) =>
appendProjectRevision(tx, input)
const forwardPayloadJson = serializeProjectCommand(input.forward);
const inversePayloadJson = serializeProjectCommand(input.inverse);
const revisionId = crypto.randomUUID();
const changeSetId = crypto.randomUUID();
const revisionNumber = input.expectedRevision + 1;
const createdAtIso = new Date().toISOString();
return this.database.transaction((tx) => {
const revisionUpdate = tx
.update(projects)
.set({ currentRevision: revisionNumber })
.where(
and(
eq(projects.id, input.projectId),
eq(projects.currentRevision, input.expectedRevision)
)
)
.run();
if (revisionUpdate.changes !== 1) {
const current = tx
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
throw new ProjectRevisionConflictError(
input.projectId,
input.expectedRevision,
current?.currentRevision ?? null
);
}
tx.insert(projectRevisions)
.values({
id: revisionId,
projectId: input.projectId,
revisionNumber,
createdAtIso,
actorId: input.actorId,
source: input.source,
description: input.description,
})
.run();
tx.insert(projectChangeSets)
.values({
id: changeSetId,
projectRevisionId: revisionId,
commandType: input.forward.type,
payloadSchemaVersion: input.forward.schemaVersion,
forwardPayloadJson,
inversePayloadJson,
})
.run();
return {
revisionId,
changeSetId,
projectId: input.projectId,
revisionNumber,
createdAtIso,
};
});
}
private validateInput(input: AppendProjectRevisionInput) {
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) {
throw new Error("Expected project revision must be a non-negative integer.");
}
if (
input.forward.schemaVersion !== input.inverse.schemaVersion
) {
throw new Error(
"Forward and inverse project commands require the same schema version."
); );
} }
} }
}
@@ -0,0 +1,14 @@
export class ProjectRevisionConflictError extends Error {
constructor(
readonly projectId: string,
readonly expectedRevision: number,
readonly actualRevision: number | null
) {
super(
actualRevision === null
? `Project ${projectId} does not exist.`
: `Project ${projectId} is at revision ${actualRevision}, expected ${expectedRevision}.`
);
this.name = "ProjectRevisionConflictError";
}
}
@@ -0,0 +1,168 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitUpdateCommandType = "circuit.update" as const;
export const circuitUpdateCommandSchemaVersion = 1 as const;
export interface CircuitUpdateValues {
sectionId: string;
equipmentIdentifier: string;
displayName: string | null;
sortOrder: number;
protectionType: string | null;
protectionRatedCurrent: number | null;
protectionCharacteristic: string | null;
cableType: string | null;
cableCrossSection: string | null;
cableLength: number | null;
rcdAssignment: string | null;
terminalDesignation: string | null;
voltage: number | null;
controlRequirement: string | null;
status: string | null;
isReserve: boolean;
remark: string | null;
}
export type CircuitUpdateField = keyof CircuitUpdateValues;
export type CircuitUpdatePatch = Partial<CircuitUpdateValues>;
export interface CircuitUpdateFieldChange<
TField extends CircuitUpdateField = CircuitUpdateField,
> {
field: TField;
value: CircuitUpdateValues[TField];
}
export interface CircuitUpdateCommandPayload {
circuitId: string;
changes: CircuitUpdateFieldChange[];
}
export interface CircuitUpdateProjectCommand
extends SerializedProjectCommand<CircuitUpdateCommandPayload> {
schemaVersion: typeof circuitUpdateCommandSchemaVersion;
type: typeof circuitUpdateCommandType;
}
export function createCircuitUpdateProjectCommand(
circuitId: string,
patch: CircuitUpdatePatch
): CircuitUpdateProjectCommand {
const changes = Object.entries(patch).map(([field, value]) => ({
field: field as CircuitUpdateField,
value,
})) as CircuitUpdateFieldChange[];
const command: CircuitUpdateProjectCommand = {
schemaVersion: circuitUpdateCommandSchemaVersion,
type: circuitUpdateCommandType,
payload: { circuitId, changes },
};
assertCircuitUpdateProjectCommand(command);
return command;
}
export function assertCircuitUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitUpdateProjectCommand {
if (
command.schemaVersion !== circuitUpdateCommandSchemaVersion ||
command.type !== circuitUpdateCommandType
) {
throw new Error("Unsupported circuit update command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Circuit update payload must be an object.");
}
const circuitId = command.payload.circuitId;
const changes = command.payload.changes;
if (typeof circuitId !== "string" || !circuitId.trim()) {
throw new Error("Circuit update command requires a circuit id.");
}
if (!Array.isArray(changes) || changes.length === 0) {
throw new Error("Circuit update command requires at least one change.");
}
const seenFields = new Set<string>();
for (const change of changes) {
if (!isPlainObject(change) || typeof change.field !== "string") {
throw new Error("Circuit update command contains an invalid change.");
}
if (!isCircuitUpdateField(change.field) || seenFields.has(change.field)) {
throw new Error("Circuit update command contains an invalid or duplicate field.");
}
assertCircuitUpdateFieldValue(change.field, change.value);
seenFields.add(change.field);
}
}
function assertCircuitUpdateFieldValue(
field: CircuitUpdateField,
value: unknown
) {
if (field === "sectionId" || field === "equipmentIdentifier") {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
return;
}
if (field === "sortOrder") {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error("sortOrder must be a finite number.");
}
return;
}
if (field === "isReserve") {
if (typeof value !== "boolean") {
throw new Error("isReserve must be a boolean.");
}
return;
}
if (
field === "protectionRatedCurrent" ||
field === "cableLength" ||
field === "voltage"
) {
if (value === null) {
return;
}
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${field} must be a finite number or null.`);
}
if (
(field === "voltage" && value <= 0) ||
(field !== "voltage" && value < 0)
) {
throw new Error(`${field} is outside its allowed range.`);
}
return;
}
if (value !== null && typeof value !== "string") {
throw new Error(`${field} must be a string or null.`);
}
}
function isCircuitUpdateField(value: string): value is CircuitUpdateField {
return [
"sectionId",
"equipmentIdentifier",
"displayName",
"sortOrder",
"protectionType",
"protectionRatedCurrent",
"protectionCharacteristic",
"cableType",
"cableCrossSection",
"cableLength",
"rcdAssignment",
"terminalDesignation",
"voltage",
"controlRequirement",
"status",
"isReserve",
"remark",
].includes(value);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
+8 -4
View File
@@ -6,18 +6,22 @@ export type ProjectCommandJsonValue =
| ProjectCommandJsonValue[] | ProjectCommandJsonValue[]
| { [key: string]: ProjectCommandJsonValue }; | { [key: string]: ProjectCommandJsonValue };
export interface SerializedProjectCommand { export interface SerializedProjectCommand<TPayload = ProjectCommandJsonValue> {
schemaVersion: number; schemaVersion: number;
type: string; type: string;
payload: ProjectCommandJsonValue; payload: TPayload;
} }
export function serializeProjectCommand(command: SerializedProjectCommand): string { export function serializeProjectCommand(
command: SerializedProjectCommand<unknown>
): string {
assertSerializedProjectCommand(command); assertSerializedProjectCommand(command);
return JSON.stringify(command); return JSON.stringify(command);
} }
export function deserializeProjectCommand(serialized: string): SerializedProjectCommand { export function deserializeProjectCommand(
serialized: string
): SerializedProjectCommand<ProjectCommandJsonValue> {
const parsed: unknown = JSON.parse(serialized); const parsed: unknown = JSON.parse(serialized);
assertSerializedProjectCommand(parsed); assertSerializedProjectCommand(parsed);
return parsed; return parsed;
@@ -0,0 +1,25 @@
import type { CircuitUpdateProjectCommand } from "../models/circuit-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitUpdateCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
command: CircuitUpdateProjectCommand;
}
export interface ExecutedCircuitUpdateCommand {
revision: AppendedProjectRevision;
inverse: CircuitUpdateProjectCommand;
}
export interface CircuitProjectCommandStore {
executeUpdate(
input: ExecuteCircuitUpdateCommandInput
): ExecutedCircuitUpdateCommand;
}
+2 -2
View File
@@ -13,8 +13,8 @@ export interface AppendProjectRevisionInput {
source: ProjectRevisionSource; source: ProjectRevisionSource;
description?: string; description?: string;
actorId?: string; actorId?: string;
forward: SerializedProjectCommand; forward: SerializedProjectCommand<unknown>;
inverse: SerializedProjectCommand; inverse: SerializedProjectCommand<unknown>;
} }
export interface AppendedProjectRevision { export interface AppendedProjectRevision {
@@ -0,0 +1,4 @@
import { db } from "../../db/client.js";
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
@@ -0,0 +1,316 @@
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 { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projects } from "../src/db/schema/projects.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
import { deserializeProjectCommand } from "../src/domain/models/project-command.model.js";
interface TestFixture {
context: DatabaseContext;
circuitListId: string;
sectionId: string;
foreignSectionId: string;
}
function createTestFixture(): TestFixture {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Other project" },
])
.run();
const repository = new DistributionBoardRepository(context.db);
const board = repository.createWithCircuitListAndDefaultSections(
"project-1",
"UV-01"
);
const foreignBoard = repository.createWithCircuitListAndDefaultSections(
"project-2",
"UV-02"
);
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
const foreignSection = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, foreignBoard.id))
.get();
assert.ok(section);
assert.ok(foreignSection);
context.db
.insert(circuits)
.values([
{
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Licht Bestand",
sortOrder: 10,
protectionRatedCurrent: 10,
isReserve: 0,
},
{
id: "circuit-2",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F2",
displayName: "Zweiter Stromkreis",
sortOrder: 20,
isReserve: 0,
},
])
.run();
return {
context,
circuitListId: board.id,
sectionId: section.id,
foreignSectionId: foreignSection.id,
};
}
function getCircuit(context: DatabaseContext) {
const circuit = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(circuit);
return circuit;
}
describe("circuit project-command repository", () => {
it("commits a circuit update, inverse command and revision together", () => {
const fixture = createTestFixture();
try {
const store = new CircuitProjectCommandRepository(fixture.context.db);
const command = createCircuitUpdateProjectCommand("circuit-1", {
displayName: null,
protectionRatedCurrent: 16,
isReserve: true,
});
const executed = store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
description: "Stromkreis bearbeiten",
command,
});
const circuit = getCircuit(fixture.context);
assert.equal(circuit.displayName, null);
assert.equal(circuit.protectionRatedCurrent, 16);
assert.equal(circuit.isReserve, 1);
assert.equal(executed.revision.revisionNumber, 1);
assert.deepEqual(executed.inverse.payload, {
circuitId: "circuit-1",
changes: [
{ field: "displayName", value: "Licht Bestand" },
{ field: "protectionRatedCurrent", value: 10 },
{ field: "isReserve", value: false },
],
});
const changeSet = fixture.context.db
.select()
.from(projectChangeSets)
.get();
assert.ok(changeSet);
assert.deepEqual(deserializeProjectCommand(changeSet.forwardPayloadJson), command);
assert.deepEqual(
deserializeProjectCommand(changeSet.inversePayloadJson),
executed.inverse
);
assert.equal(
fixture.context.db
.select()
.from(projects)
.where(eq(projects.id, "project-1"))
.get()?.currentRevision,
1
);
} finally {
fixture.context.close();
}
});
it("can apply the generated inverse as a new auditable revision", () => {
const fixture = createTestFixture();
try {
const store = new CircuitProjectCommandRepository(fixture.context.db);
const forward = store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Licht Neu",
protectionRatedCurrent: 16,
}),
});
const undone = store.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
description: "Stromkreisänderung rückgängig machen",
command: forward.inverse,
});
const circuit = getCircuit(fixture.context);
assert.equal(circuit.displayName, "Licht Bestand");
assert.equal(circuit.protectionRatedCurrent, 10);
assert.equal(undone.revision.revisionNumber, 2);
assert.deepEqual(
fixture.context.db
.select({ source: projectRevisions.source })
.from(projectRevisions)
.all()
.map((revision) => revision.source),
["user", "undo"]
);
} finally {
fixture.context.close();
}
});
it("rolls back the circuit update when the expected revision is stale", () => {
const fixture = createTestFixture();
try {
const store = new CircuitProjectCommandRepository(fixture.context.db);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Erste Änderung",
}),
});
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Veraltete Änderung",
}),
}),
(error) =>
error instanceof ProjectRevisionConflictError &&
error.actualRevision === 1
);
assert.equal(getCircuit(fixture.context).displayName, "Erste Änderung");
assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 1);
assert.equal(fixture.context.db.select().from(projectChangeSets).all().length, 1);
} finally {
fixture.context.close();
}
});
it("rolls back the circuit update when late history persistence fails", () => {
const fixture = createTestFixture();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_circuit_command_change_set
BEFORE INSERT ON project_change_sets
BEGIN
SELECT RAISE(ABORT, 'forced command history failure');
END;
`);
const store = new CircuitProjectCommandRepository(fixture.context.db);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Darf nicht bleiben",
}),
}),
/forced command history failure/
);
assert.equal(getCircuit(fixture.context).displayName, "Licht Bestand");
assert.equal(
fixture.context.db
.select()
.from(projects)
.where(eq(projects.id, "project-1"))
.get()?.currentRevision,
0
);
assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 0);
assert.equal(fixture.context.db.select().from(projectChangeSets).all().length, 0);
} finally {
fixture.context.close();
}
});
it("rejects foreign sections and duplicate equipment identifiers before history", () => {
const fixture = createTestFixture();
try {
const store = new CircuitProjectCommandRepository(fixture.context.db);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
sectionId: fixture.foreignSectionId,
}),
}),
/Section does not belong/
);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
equipmentIdentifier: "-1F2",
}),
}),
/Duplicate equipmentIdentifier/
);
const circuit = getCircuit(fixture.context);
assert.equal(circuit.sectionId, fixture.sectionId);
assert.equal(circuit.equipmentIdentifier, "-1F1");
assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 0);
} finally {
fixture.context.close();
}
});
});
+50
View File
@@ -5,6 +5,10 @@ import {
serializeProjectCommand, serializeProjectCommand,
type SerializedProjectCommand, type SerializedProjectCommand,
} from "../src/domain/models/project-command.model.js"; } from "../src/domain/models/project-command.model.js";
import {
assertCircuitUpdateProjectCommand,
createCircuitUpdateProjectCommand,
} from "../src/domain/models/circuit-project-command.model.js";
describe("serialized project commands", () => { describe("serialized project commands", () => {
it("round-trips a versioned command envelope", () => { it("round-trips a versioned command envelope", () => {
@@ -72,3 +76,49 @@ describe("serialized project commands", () => {
); );
}); });
}); });
describe("circuit update project commands", () => {
it("creates a typed command with explicit null clearing semantics", () => {
const command = createCircuitUpdateProjectCommand("circuit-1", {
displayName: null,
cableLength: 12.5,
isReserve: false,
});
assert.doesNotThrow(() => assertCircuitUpdateProjectCommand(command));
assert.deepEqual(command.payload.changes, [
{ field: "displayName", value: null },
{ field: "cableLength", value: 12.5 },
{ field: "isReserve", value: false },
]);
});
it("rejects empty, duplicate and invalid field changes", () => {
assert.throws(
() => createCircuitUpdateProjectCommand("circuit-1", {}),
/at least one change/
);
assert.throws(
() =>
assertCircuitUpdateProjectCommand({
schemaVersion: 1,
type: "circuit.update",
payload: {
circuitId: "circuit-1",
changes: [
{ field: "displayName", value: "A" },
{ field: "displayName", value: "B" },
],
},
}),
/duplicate field/
);
assert.throws(
() =>
createCircuitUpdateProjectCommand("circuit-1", {
voltage: 0,
}),
/outside its allowed range/
);
});
});