Add atomic device row updates

This commit is contained in:
2026-07-23 21:38:20 +02:00
parent 296cb0f1c4
commit 2d0aa11d9c
14 changed files with 918 additions and 53 deletions
+5 -3
View File
@@ -69,9 +69,11 @@ und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und
bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze
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
angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren interne
Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und
Revision gemeinsam committen beziehungsweise zurückrollen können.
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Die Stores sind noch
nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo ist daher
noch nicht verfügbar.
@@ -153,14 +153,19 @@ Completed foundation:
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
- an internal `circuit-device-row.update` command applies the same boundary to
device fields, validates project-device and room ownership and records
automatically derived local override metadata in its normalized forward
command
- integration tests apply the generated inverse as a new `undo` revision and
verify rollback for stale revisions and late history failures
verify rollback for stale revisions and late history failures on both update
command types
Remaining constraints before implementing history:
- extend the concrete project-scoped command union and executors beyond the
internal `circuit.update` slice; the generic versioned command envelope is
complete
internal Circuit and CircuitDeviceRow update slices; the generic versioned
command envelope is complete
- route domain writes and revision recording through one shared project
transaction boundary; the current revision repository only commits history
records and the counter together
@@ -376,6 +376,9 @@ Implemented foundation:
persisted pre-command state
- circuit update, inverse command, change set and revision share one SQLite
transaction with stale-command and late-failure rollback coverage
- an internal typed `circuit-device-row.update` command uses the same boundary,
records derived ProjectDevice override metadata and rejects cross-project
rows, device links and rooms
- revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction
- a stale expected revision produces no history writes
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"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/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/circuit-project-command.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/circuit-device-row-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/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/database-backup.test.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts",
@@ -0,0 +1,182 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitDeviceRowUpdateProjectCommand,
createCircuitDeviceRowUpdateProjectCommand,
type CircuitDeviceRowUpdateField,
type CircuitDeviceRowUpdatePatch,
type CircuitDeviceRowUpdateValues,
} from "../../domain/models/circuit-device-row-project-command.model.js";
import type {
CircuitDeviceRowProjectCommandStore,
ExecuteCircuitDeviceRowUpdateCommandInput,
} from "../../domain/ports/circuit-device-row-project-command.store.js";
import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js";
import { rooms } from "../schema/rooms.js";
import {
toCircuitDeviceRowPatchValues,
type CircuitDeviceRowPatchInput,
} from "./circuit-device-row.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
export class CircuitDeviceRowProjectCommandRepository
implements CircuitDeviceRowProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) {
assertCircuitDeviceRowUpdateProjectCommand(input.command);
return this.database.transaction((tx) => {
const current = tx
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, input.command.payload.rowId))
.get();
if (!current) {
throw new Error("Invalid device row id.");
}
const circuit = tx
.select({ circuitListId: circuits.circuitListId })
.from(circuits)
.where(eq(circuits.id, current.circuitId))
.get();
const owningList = circuit
? tx
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, circuit.circuitListId),
eq(circuitLists.projectId, input.projectId)
)
)
.get()
: null;
if (!owningList) {
throw new Error("Circuit device row does not belong to project.");
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as CircuitDeviceRowPatchInput;
this.assertLinkedProjectDevice(tx, input.projectId, patch);
this.assertRoom(tx, input.projectId, patch);
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
current,
patch
);
if (
overriddenFields !== undefined &&
patch.overriddenFields === undefined
) {
patch.overriddenFields = overriddenFields;
}
const appliedForward = createCircuitDeviceRowUpdateProjectCommand(
current.id,
patch as CircuitDeviceRowUpdatePatch
);
const inversePatch = Object.fromEntries(
appliedForward.payload.changes.map((change) => [
change.field,
getCircuitDeviceRowFieldValue(current, change.field),
])
) as CircuitDeviceRowUpdatePatch;
const inverse = createCircuitDeviceRowUpdateProjectCommand(
current.id,
inversePatch
);
const update = tx
.update(circuitDeviceRows)
.set(toCircuitDeviceRowPatchValues(patch))
.where(eq(circuitDeviceRows.id, current.id))
.run();
if (update.changes !== 1) {
throw new Error("Circuit device row changed before command execution.");
}
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: appliedForward,
inverse,
});
return { revision, inverse };
});
}
private assertLinkedProjectDevice(
database: AppDatabase,
projectId: string,
patch: CircuitDeviceRowPatchInput
) {
if (
!Object.prototype.hasOwnProperty.call(patch, "linkedProjectDeviceId") ||
patch.linkedProjectDeviceId === null ||
patch.linkedProjectDeviceId === undefined
) {
return;
}
const device = database
.select({ id: projectDevices.id })
.from(projectDevices)
.where(
and(
eq(projectDevices.id, patch.linkedProjectDeviceId),
eq(projectDevices.projectId, projectId)
)
)
.get();
if (!device) {
throw new Error("Invalid linked project device id.");
}
}
private assertRoom(
database: AppDatabase,
projectId: string,
patch: CircuitDeviceRowPatchInput
) {
if (
!Object.prototype.hasOwnProperty.call(patch, "roomId") ||
patch.roomId === null ||
patch.roomId === undefined
) {
return;
}
const room = database
.select({ id: rooms.id })
.from(rooms)
.where(and(eq(rooms.id, patch.roomId), eq(rooms.projectId, projectId)))
.get();
if (!room) {
throw new Error("Invalid room id.");
}
}
}
function getCircuitDeviceRowFieldValue<
TField extends CircuitDeviceRowUpdateField,
>(
row: CircuitDeviceRow,
field: TField
): CircuitDeviceRowUpdateValues[TField] {
return row[field] as unknown as CircuitDeviceRowUpdateValues[TField];
}
@@ -20,9 +20,26 @@ export interface CircuitDeviceRowUpdateInput {
overriddenFields?: string;
}
export type CircuitDeviceRowPatchInput = Partial<CircuitDeviceRowUpdateInput> & {
export interface CircuitDeviceRowPatchInput {
linkedProjectDeviceId?: string | null;
sortOrder?: number;
};
name?: string;
displayName?: string;
phaseType?: string | null;
connectionKind?: string | null;
costGroup?: string | null;
category?: string | null;
level?: string | null;
roomId?: string | null;
roomNumberSnapshot?: string | null;
roomNameSnapshot?: string | null;
quantity?: number;
powerPerUnit?: number;
simultaneityFactor?: number;
cosPhi?: number | null;
remark?: string | null;
overriddenFields?: string | null;
}
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
circuitId: string;
@@ -0,0 +1,177 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitDeviceRowUpdateCommandType =
"circuit-device-row.update" as const;
export const circuitDeviceRowUpdateCommandSchemaVersion = 1 as const;
export interface CircuitDeviceRowUpdateValues {
linkedProjectDeviceId: string | null;
sortOrder: number;
name: string;
displayName: string;
phaseType: string | null;
connectionKind: string | null;
costGroup: string | null;
category: string | null;
level: string | null;
roomId: string | null;
roomNumberSnapshot: string | null;
roomNameSnapshot: string | null;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi: number | null;
remark: string | null;
overriddenFields: string | null;
}
export type CircuitDeviceRowUpdateField = keyof CircuitDeviceRowUpdateValues;
export type CircuitDeviceRowUpdatePatch =
Partial<CircuitDeviceRowUpdateValues>;
export interface CircuitDeviceRowUpdateFieldChange<
TField extends CircuitDeviceRowUpdateField = CircuitDeviceRowUpdateField,
> {
field: TField;
value: CircuitDeviceRowUpdateValues[TField];
}
export interface CircuitDeviceRowUpdateCommandPayload {
rowId: string;
changes: CircuitDeviceRowUpdateFieldChange[];
}
export interface CircuitDeviceRowUpdateProjectCommand
extends SerializedProjectCommand<CircuitDeviceRowUpdateCommandPayload> {
schemaVersion: typeof circuitDeviceRowUpdateCommandSchemaVersion;
type: typeof circuitDeviceRowUpdateCommandType;
}
export function createCircuitDeviceRowUpdateProjectCommand(
rowId: string,
patch: CircuitDeviceRowUpdatePatch
): CircuitDeviceRowUpdateProjectCommand {
const changes = Object.entries(patch).map(([field, value]) => ({
field: field as CircuitDeviceRowUpdateField,
value,
})) as CircuitDeviceRowUpdateFieldChange[];
const command: CircuitDeviceRowUpdateProjectCommand = {
schemaVersion: circuitDeviceRowUpdateCommandSchemaVersion,
type: circuitDeviceRowUpdateCommandType,
payload: { rowId, changes },
};
assertCircuitDeviceRowUpdateProjectCommand(command);
return command;
}
export function assertCircuitDeviceRowUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitDeviceRowUpdateProjectCommand {
if (
command.schemaVersion !== circuitDeviceRowUpdateCommandSchemaVersion ||
command.type !== circuitDeviceRowUpdateCommandType
) {
throw new Error("Unsupported circuit device-row update command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Circuit device-row update payload must be an object.");
}
const rowId = command.payload.rowId;
const changes = command.payload.changes;
if (typeof rowId !== "string" || !rowId.trim()) {
throw new Error("Circuit device-row update command requires a row id.");
}
if (!Array.isArray(changes) || changes.length === 0) {
throw new Error(
"Circuit device-row 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 device-row update command contains an invalid change."
);
}
if (
!isCircuitDeviceRowUpdateField(change.field) ||
seenFields.has(change.field)
) {
throw new Error(
"Circuit device-row update command contains an invalid or duplicate field."
);
}
assertCircuitDeviceRowUpdateFieldValue(change.field, change.value);
seenFields.add(change.field);
}
}
function assertCircuitDeviceRowUpdateFieldValue(
field: CircuitDeviceRowUpdateField,
value: unknown
) {
if (field === "name" || field === "displayName") {
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 === "quantity" ||
field === "powerPerUnit" ||
field === "simultaneityFactor"
) {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative finite number.`);
}
return;
}
if (field === "cosPhi") {
if (
value !== null &&
(typeof value !== "number" || !Number.isFinite(value) || value <= 0)
) {
throw new Error("cosPhi must be a positive finite number or null.");
}
return;
}
if (value !== null && typeof value !== "string") {
throw new Error(`${field} must be a string or null.`);
}
}
function isCircuitDeviceRowUpdateField(
value: string
): value is CircuitDeviceRowUpdateField {
return [
"linkedProjectDeviceId",
"sortOrder",
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"level",
"roomId",
"roomNumberSnapshot",
"roomNameSnapshot",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"overriddenFields",
].includes(value);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,25 @@
import type { CircuitDeviceRowUpdateProjectCommand } from "../models/circuit-device-row-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitDeviceRowUpdateCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
command: CircuitDeviceRowUpdateProjectCommand;
}
export interface ExecutedCircuitDeviceRowUpdateCommand {
revision: AppendedProjectRevision;
inverse: CircuitDeviceRowUpdateProjectCommand;
}
export interface CircuitDeviceRowProjectCommandStore {
executeUpdate(
input: ExecuteCircuitDeviceRowUpdateCommandInput
): ExecutedCircuitDeviceRowUpdateCommand;
}
+5 -20
View File
@@ -17,11 +17,7 @@ import type {
UpdateCircuitInput,
} from "../../shared/validation/circuit.schemas.js";
import { CircuitNumberingService } from "./circuit-numbering.service.js";
import { projectDeviceSyncFields } from "../../shared/constants/project-device-sync-fields.js";
import {
parseOverriddenFields,
serializeOverriddenFields,
} from "./project-device-sync.service.js";
import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js";
export class CircuitWriteService {
private readonly circuitRepository: CircuitRepository;
@@ -256,21 +252,10 @@ export class CircuitWriteService {
throw new Error("Invalid device row id.");
}
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
let overriddenFields = input.overriddenFields;
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
const inputValues = input as Record<string, unknown>;
const currentValues = current as unknown as Record<string, unknown>;
for (const field of projectDeviceSyncFields) {
if (
Object.prototype.hasOwnProperty.call(input, field) &&
(inputValues[field] ?? null) !== (currentValues[field] ?? null)
) {
overrides.add(field);
}
}
overriddenFields = serializeOverriddenFields(overrides);
}
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
current,
input
);
await this.deviceRowRepository.updateFields(rowId, {
...input,
...(overriddenFields !== undefined ? { overriddenFields } : {}),
@@ -0,0 +1,57 @@
import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
} from "../../shared/constants/project-device-sync-fields.js";
export function parseOverriddenFields(
value: string | null | undefined
): ProjectDeviceSyncField[] {
if (!value) {
return [];
}
try {
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((field): field is ProjectDeviceSyncField =>
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
);
} catch {
return [];
}
}
export function serializeOverriddenFields(
fields: Iterable<ProjectDeviceSyncField>
): string | undefined {
const fieldSet = new Set(fields);
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
return unique.length > 0 ? JSON.stringify(unique) : undefined;
}
export function deriveOverriddenFieldsForLocalEdit(
current: {
linkedProjectDeviceId: string | null | undefined;
overriddenFields: string | null | undefined;
} & Partial<Record<ProjectDeviceSyncField, unknown>>,
patch: Partial<Record<ProjectDeviceSyncField | "overriddenFields", unknown>>
): string | null | undefined {
if (
!current.linkedProjectDeviceId ||
Object.prototype.hasOwnProperty.call(patch, "overriddenFields")
) {
return patch.overriddenFields as string | null | undefined;
}
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
for (const field of projectDeviceSyncFields) {
if (
Object.prototype.hasOwnProperty.call(patch, field) &&
(patch[field] ?? null) !== (current[field] ?? null)
) {
overrides.add(field);
}
}
return serializeOverriddenFields(overrides);
}
@@ -5,6 +5,15 @@ import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
} from "../../shared/constants/project-device-sync-fields.js";
import {
parseOverriddenFields,
serializeOverriddenFields,
} from "./project-device-overrides.js";
export {
parseOverriddenFields,
serializeOverriddenFields,
} from "./project-device-overrides.js";
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>;
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
@@ -25,29 +34,6 @@ export interface ProjectDeviceSyncRestoreRow {
overriddenFields: ProjectDeviceSyncField[];
}
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
if (!value) {
return [];
}
try {
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((field): field is ProjectDeviceSyncField =>
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
);
} catch {
return [];
}
}
export function serializeOverriddenFields(fields: Iterable<ProjectDeviceSyncField>): string | undefined {
const fieldSet = new Set(fields);
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
return unique.length > 0 ? JSON.stringify(unique) : undefined;
}
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
return projectDevice[field];
}
@@ -1,4 +1,7 @@
import { db } from "../../db/client.js";
import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
export const circuitDeviceRowProjectCommandStore =
new CircuitDeviceRowProjectCommandRepository(db);
@@ -0,0 +1,379 @@
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 { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
import { deserializeProjectCommand } from "../src/domain/models/project-command.model.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Other project" },
])
.run();
const boards = new DistributionBoardRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections(
"project-1",
"UV-01"
);
const foreignBoard = boards.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",
sortOrder: 10,
},
{
id: "circuit-2",
circuitListId: foreignBoard.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
])
.run();
context.db
.insert(projectDevices)
.values([
{
id: "project-device-1",
projectId: "project-1",
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
id: "project-device-2",
projectId: "project-2",
name: "Fremdgerät",
displayName: "Fremdgerät",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
])
.run();
context.db
.insert(rooms)
.values([
{
id: "room-1",
projectId: "project-1",
floorId: null,
roomNumber: "001",
roomName: "Büro",
},
{
id: "room-2",
projectId: "project-2",
floorId: null,
roomNumber: "999",
roomName: "Fremdraum",
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte lokal",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
id: "row-2",
circuitId: "circuit-2",
sortOrder: 10,
name: "Fremdzeile",
displayName: "Fremdzeile",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
])
.run();
return context;
}
function getRow(context: DatabaseContext) {
const row = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get();
assert.ok(row);
return row;
}
describe("circuit device-row project-command repository", () => {
it("commits the row update, tracked override, inverse and revision together", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
const executed = store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
description: "Gerätezeile bearbeiten",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
roomId: "room-1",
remark: null,
}),
});
const row = getRow(context);
assert.equal(row.quantity, 2);
assert.equal(row.roomId, "room-1");
assert.equal(row.remark, null);
assert.equal(row.overriddenFields, "[\"quantity\"]");
assert.equal(executed.revision.revisionNumber, 1);
assert.deepEqual(executed.inverse.payload, {
rowId: "row-1",
changes: [
{ field: "quantity", value: 1 },
{ field: "roomId", value: null },
{ field: "remark", value: null },
{ field: "overriddenFields", value: null },
],
});
const changeSet = context.db.select().from(projectChangeSets).get();
assert.ok(changeSet);
const storedForward = deserializeProjectCommand(
changeSet.forwardPayloadJson
);
assert.deepEqual(storedForward, {
schemaVersion: 1,
type: "circuit-device-row.update",
payload: {
rowId: "row-1",
changes: [
{ field: "quantity", value: 2 },
{ field: "roomId", value: "room-1" },
{ field: "remark", value: null },
{ field: "overriddenFields", value: "[\"quantity\"]" },
],
},
});
} finally {
context.close();
}
});
it("applies the generated inverse and restores override metadata", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
const forward = store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 3,
}),
});
const undone = store.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
command: forward.inverse,
});
const row = getRow(context);
assert.equal(row.quantity, 1);
assert.equal(row.overriddenFields, null);
assert.equal(undone.revision.revisionNumber, 2);
assert.deepEqual(
context.db
.select({ source: projectRevisions.source })
.from(projectRevisions)
.all()
.map((revision) => revision.source),
["user", "undo"]
);
} finally {
context.close();
}
});
it("rolls back the row and override update for a stale revision", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
}),
});
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 4,
}),
}),
(error) =>
error instanceof ProjectRevisionConflictError &&
error.actualRevision === 1
);
const row = getRow(context);
assert.equal(row.quantity, 2);
assert.equal(row.overriddenFields, "[\"quantity\"]");
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
} finally {
context.close();
}
});
it("rolls back the row update when late history persistence fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_device_row_command_change_set
BEFORE INSERT ON project_change_sets
BEGIN
SELECT RAISE(ABORT, 'forced device-row history failure');
END;
`);
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 5,
}),
}),
/forced device-row history failure/
);
const row = getRow(context);
assert.equal(row.quantity, 1);
assert.equal(row.overriddenFields, null);
assert.equal(
context.db
.select()
.from(projects)
.where(eq(projects.id, "project-1"))
.get()?.currentRevision,
0
);
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
} finally {
context.close();
}
});
it("rejects cross-project rows, devices and rooms without history", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-2", {
quantity: 2,
}),
}),
/does not belong to project/
);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
linkedProjectDeviceId: "project-device-2",
}),
}),
/Invalid linked project device/
);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
roomId: "room-2",
}),
}),
/Invalid room/
);
assert.equal(getRow(context).linkedProjectDeviceId, "project-device-1");
assert.equal(getRow(context).roomId, null);
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
} finally {
context.close();
}
});
});
+44
View File
@@ -9,6 +9,10 @@ import {
assertCircuitUpdateProjectCommand,
createCircuitUpdateProjectCommand,
} from "../src/domain/models/circuit-project-command.model.js";
import {
assertCircuitDeviceRowUpdateProjectCommand,
createCircuitDeviceRowUpdateProjectCommand,
} from "../src/domain/models/circuit-device-row-project-command.model.js";
describe("serialized project commands", () => {
it("round-trips a versioned command envelope", () => {
@@ -122,3 +126,43 @@ describe("circuit update project commands", () => {
);
});
});
describe("circuit device-row update project commands", () => {
it("creates typed nullable device-row changes", () => {
const command = createCircuitDeviceRowUpdateProjectCommand("row-1", {
roomId: null,
quantity: 2,
cosPhi: 0.9,
});
assert.doesNotThrow(() =>
assertCircuitDeviceRowUpdateProjectCommand(command)
);
assert.deepEqual(command.payload.changes, [
{ field: "roomId", value: null },
{ field: "quantity", value: 2 },
{ field: "cosPhi", value: 0.9 },
]);
});
it("rejects invalid device-row values", () => {
assert.throws(
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {}),
/at least one change/
);
assert.throws(
() =>
createCircuitDeviceRowUpdateProjectCommand("row-1", {
displayName: "",
}),
/non-empty string/
);
assert.throws(
() =>
createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: -1,
}),
/non-negative/
);
});
});