Add device row history commands

This commit is contained in:
2026-07-23 22:09:53 +02:00
parent e4c7cf06e9
commit bd5f4f925f
14 changed files with 1109 additions and 13 deletions
+3 -2
View File
@@ -223,8 +223,9 @@ After saving, the row becomes linked to the new project device.
Session-local UI undo/redo exists for structural and destructive operations.
The server already persists immutable revisions and project-wide undo/redo
eligibility for the first commands. Public command, undo and redo endpoints
exist for Circuit and CircuitDeviceRow field updates. Complete command coverage
and the UI cutover remain future work.
exist for Circuit and CircuitDeviceRow field updates as well as
CircuitDeviceRow insertion/deletion. Complete command coverage and the UI
cutover remain future work.
Required operations:
+7 -3
View File
@@ -23,9 +23,13 @@ concurrency checks.
- executes the eligible inverse or forward command as a new auditable
revision
The public dispatcher currently supports `circuit.update` and
`circuit-device-row.update`, both with schema version `1`. Other command types
are rejected. The editor does not consume these endpoints yet.
The public dispatcher currently supports `circuit.update`,
`circuit-device-row.update`, `circuit-device-row.insert` and
`circuit-device-row.delete`, all with schema version `1`. Other command types
are rejected. Insert commands contain the complete row with its stable id;
delete commands identify the row and its expected circuit. This makes deletion
undoable without generating a replacement identity. The editor does not
consume these endpoints yet.
Example:
+8 -4
View File
@@ -78,10 +78,14 @@ persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
`GET /api/projects/:projectId/history` lesbar. Ein zentraler Dispatcher führt
die unterstützten Typen `circuit.update` und `circuit-device-row.update` über
öffentliche Command-, Undo- und Redo-Endpunkte aus. Das Grid verwendet diese
Endpunkte noch nicht; seine sichtbare Historie bleibt daher sitzungslokal.
Struktur-, Synchronisierungs- und Löschoperationen müssen vor der
Frontend-Umstellung ebenfalls als persistente Commands modelliert werden.
öffentliche Command-, Undo- und Redo-Endpunkte aus. Zusätzlich sind
`circuit-device-row.insert` und `circuit-device-row.delete` als atomare
Strukturkommandos vorhanden. Beim Löschen wird die vollständige Zeile im
inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte
wiederherstellt. Das Grid verwendet diese Endpunkte noch nicht; seine sichtbare
Historie bleibt daher sitzungslokal. Die übrigen Struktur-, Synchronisierungs-
und Löschoperationen müssen vor der Frontend-Umstellung ebenfalls als
persistente Commands modelliert werden.
## Projektgeräte
@@ -173,12 +173,15 @@ Completed foundation:
to SQLite
- public command, undo and redo endpoints require an expected revision; undo
and redo reconstruct the eligible persisted inverse or forward command
- `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable
row ids, complete deleted-row snapshots and circuit reserve state through
atomic insert/delete, revision and stack transitions
Remaining constraints before completing persistent history:
- extend the concrete project-scoped command union and executors beyond the
internal Circuit and CircuitDeviceRow update slices; the generic versioned
command envelope is complete
Circuit field and CircuitDeviceRow field/insert/delete slices; the generic
versioned command envelope is complete
- route the remaining structural, synchronization and destructive domain
writes through the shared command, revision and stack transaction boundary
- replace the session-local frontend command stack only after server-side
@@ -390,6 +390,9 @@ Implemented foundation:
- a central application dispatcher reconstructs persisted commands and exposes
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
field updates
- typed CircuitDeviceRow insert/delete commands preserve complete row snapshots
and stable ids; row mutation, circuit reserve state, revision and history
stack transition commit or roll back together
- 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/circuit-device-row-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.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/project-history.repository.test.ts tests/project-command.service.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/circuit-device-row-structure-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.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/circuit-device-row-structure-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.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,262 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitDeviceRowDeleteProjectCommand,
assertCircuitDeviceRowInsertProjectCommand,
circuitDeviceRowDeleteCommandType,
circuitDeviceRowInsertCommandType,
createCircuitDeviceRowDeleteProjectCommand,
createCircuitDeviceRowInsertProjectCommand,
type CircuitDeviceRowSnapshot,
type CircuitDeviceRowStructureProjectCommand,
} from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type {
CircuitDeviceRowStructureProjectCommandStore,
ExecuteCircuitDeviceRowStructureCommandInput,
} from "../../domain/ports/circuit-device-row-structure-project-command.store.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 { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class CircuitDeviceRowStructureProjectCommandRepository
implements CircuitDeviceRowStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
return this.database.transaction((tx) => {
const inverse = this.applyCommand(
tx,
input.projectId,
input.source,
input.command
);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
private applyCommand(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
command: CircuitDeviceRowStructureProjectCommand
): CircuitDeviceRowStructureProjectCommand {
if (command.type === circuitDeviceRowInsertCommandType) {
assertCircuitDeviceRowInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
command.payload.row
);
}
if (command.type === circuitDeviceRowDeleteCommandType) {
assertCircuitDeviceRowDeleteProjectCommand(command);
return this.delete(
database,
projectId,
command.payload.rowId,
command.payload.expectedCircuitId
);
}
throw new Error("Unsupported circuit device-row structure command.");
}
private insert(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
row: CircuitDeviceRowSnapshot
) {
if (source === "user" && row.legacyConsumerId !== null) {
throw new Error(
"User commands cannot assign legacy consumer ids."
);
}
this.assertCircuitInProject(database, projectId, row.circuitId);
this.assertReferencesInProject(database, projectId, row);
const existing = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, row.id))
.get();
if (existing) {
throw new Error("Circuit device-row id already exists.");
}
database.insert(circuitDeviceRows).values(row).run();
const circuitUpdate = database
.update(circuits)
.set({ isReserve: 0 })
.where(eq(circuits.id, row.circuitId))
.run();
if (circuitUpdate.changes !== 1) {
throw new Error("Circuit changed before device-row insertion.");
}
return createCircuitDeviceRowDeleteProjectCommand(
row.id,
row.circuitId
);
}
private delete(
database: AppDatabase,
projectId: string,
rowId: string,
expectedCircuitId: string
) {
const row = database
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, rowId))
.get();
if (!row || row.circuitId !== expectedCircuitId) {
throw new Error(
"Circuit device row changed before command execution."
);
}
this.assertCircuitInProject(database, projectId, row.circuitId);
const result = database
.delete(circuitDeviceRows)
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.circuitId, expectedCircuitId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("Circuit device row could not be deleted.");
}
const remainingRow = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, expectedCircuitId))
.limit(1)
.get();
const circuitUpdate = database
.update(circuits)
.set({ isReserve: remainingRow ? 0 : 1 })
.where(eq(circuits.id, expectedCircuitId))
.run();
if (circuitUpdate.changes !== 1) {
throw new Error("Circuit changed before device-row deletion.");
}
return createCircuitDeviceRowInsertProjectCommand(
toCircuitDeviceRowSnapshot(row)
);
}
private assertCircuitInProject(
database: AppDatabase,
projectId: string,
circuitId: string
) {
const circuit = database
.select({ id: circuits.id })
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(
and(
eq(circuits.id, circuitId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!circuit) {
throw new Error("Circuit does not belong to project.");
}
}
private assertReferencesInProject(
database: AppDatabase,
projectId: string,
row: CircuitDeviceRowSnapshot
) {
if (row.linkedProjectDeviceId !== null) {
const device = database
.select({ id: projectDevices.id })
.from(projectDevices)
.where(
and(
eq(projectDevices.id, row.linkedProjectDeviceId),
eq(projectDevices.projectId, projectId)
)
)
.get();
if (!device) {
throw new Error("Invalid linked project device id.");
}
}
if (row.roomId !== null) {
const room = database
.select({ id: rooms.id })
.from(rooms)
.where(
and(
eq(rooms.id, row.roomId),
eq(rooms.projectId, projectId)
)
)
.get();
if (!room) {
throw new Error("Invalid room id.");
}
}
}
}
function toCircuitDeviceRowSnapshot(
row: typeof circuitDeviceRows.$inferSelect
): CircuitDeviceRowSnapshot {
return {
id: row.id,
circuitId: row.circuitId,
linkedProjectDeviceId: row.linkedProjectDeviceId,
legacyConsumerId: row.legacyConsumerId,
sortOrder: row.sortOrder,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType,
connectionKind: row.connectionKind,
costGroup: row.costGroup,
category: row.category,
level: row.level,
roomId: row.roomId,
roomNumberSnapshot: row.roomNumberSnapshot,
roomNameSnapshot: row.roomNameSnapshot,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
remark: row.remark,
overriddenFields: row.overriddenFields,
};
}
@@ -0,0 +1,186 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitDeviceRowInsertCommandType =
"circuit-device-row.insert" as const;
export const circuitDeviceRowDeleteCommandType =
"circuit-device-row.delete" as const;
export const circuitDeviceRowStructureCommandSchemaVersion = 1 as const;
export interface CircuitDeviceRowSnapshot {
id: string;
circuitId: string;
linkedProjectDeviceId: string | null;
legacyConsumerId: 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 CircuitDeviceRowInsertCommandPayload {
row: CircuitDeviceRowSnapshot;
}
export interface CircuitDeviceRowDeleteCommandPayload {
rowId: string;
expectedCircuitId: string;
}
export interface CircuitDeviceRowInsertProjectCommand
extends SerializedProjectCommand<CircuitDeviceRowInsertCommandPayload> {
schemaVersion: typeof circuitDeviceRowStructureCommandSchemaVersion;
type: typeof circuitDeviceRowInsertCommandType;
}
export interface CircuitDeviceRowDeleteProjectCommand
extends SerializedProjectCommand<CircuitDeviceRowDeleteCommandPayload> {
schemaVersion: typeof circuitDeviceRowStructureCommandSchemaVersion;
type: typeof circuitDeviceRowDeleteCommandType;
}
export type CircuitDeviceRowStructureProjectCommand =
| CircuitDeviceRowInsertProjectCommand
| CircuitDeviceRowDeleteProjectCommand;
export function createCircuitDeviceRowInsertProjectCommand(
row: CircuitDeviceRowSnapshot
): CircuitDeviceRowInsertProjectCommand {
const command: CircuitDeviceRowInsertProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType,
payload: { row },
};
assertCircuitDeviceRowInsertProjectCommand(command);
return command;
}
export function createCircuitDeviceRowDeleteProjectCommand(
rowId: string,
expectedCircuitId: string
): CircuitDeviceRowDeleteProjectCommand {
const command: CircuitDeviceRowDeleteProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowDeleteCommandType,
payload: { rowId, expectedCircuitId },
};
assertCircuitDeviceRowDeleteProjectCommand(command);
return command;
}
export function assertCircuitDeviceRowInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitDeviceRowInsertProjectCommand {
if (
command.schemaVersion !==
circuitDeviceRowStructureCommandSchemaVersion ||
command.type !== circuitDeviceRowInsertCommandType
) {
throw new Error("Unsupported circuit device-row insert command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Circuit device-row insert payload must be an object.");
}
const row = command.payload.row;
if (!isPlainObject(row)) {
throw new Error("Circuit device-row insert command requires a row.");
}
assertNonEmptyString(row.id, "row.id");
assertNonEmptyString(row.circuitId, "row.circuitId");
assertNullableString(row.linkedProjectDeviceId, "row.linkedProjectDeviceId");
assertNullableString(row.legacyConsumerId, "row.legacyConsumerId");
assertFiniteNumber(row.sortOrder, "row.sortOrder");
assertNonEmptyString(row.name, "row.name");
assertNonEmptyString(row.displayName, "row.displayName");
for (const field of [
"phaseType",
"connectionKind",
"costGroup",
"category",
"level",
"roomId",
"roomNumberSnapshot",
"roomNameSnapshot",
"remark",
"overriddenFields",
] as const) {
assertNullableString(row[field], `row.${field}`);
}
assertNonNegativeNumber(row.quantity, "row.quantity");
assertNonNegativeNumber(row.powerPerUnit, "row.powerPerUnit");
assertNonNegativeNumber(
row.simultaneityFactor,
"row.simultaneityFactor"
);
if (row.cosPhi !== null) {
assertFiniteNumber(row.cosPhi, "row.cosPhi");
if (row.cosPhi <= 0) {
throw new Error("row.cosPhi must be positive or null.");
}
}
}
export function assertCircuitDeviceRowDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitDeviceRowDeleteProjectCommand {
if (
command.schemaVersion !==
circuitDeviceRowStructureCommandSchemaVersion ||
command.type !== circuitDeviceRowDeleteCommandType
) {
throw new Error("Unsupported circuit device-row delete command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Circuit device-row delete payload must be an object.");
}
assertNonEmptyString(command.payload.rowId, "rowId");
assertNonEmptyString(
command.payload.expectedCircuitId,
"expectedCircuitId"
);
}
function assertNonEmptyString(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
function assertNullableString(value: unknown, field: string) {
if (value !== null && typeof value !== "string") {
throw new Error(`${field} must be a string or null.`);
}
}
function assertFiniteNumber(
value: unknown,
field: string
): asserts value is number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${field} must be a finite number.`);
}
}
function assertNonNegativeNumber(value: unknown, field: string) {
assertFiniteNumber(value, field);
if (value < 0) {
throw new Error(`${field} must not be negative.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,26 @@
import type { CircuitDeviceRowStructureProjectCommand } from "../models/circuit-device-row-structure-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitDeviceRowStructureCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitDeviceRowStructureProjectCommand;
}
export interface ExecutedCircuitDeviceRowStructureCommand {
revision: AppendedProjectRevision;
inverse: CircuitDeviceRowStructureProjectCommand;
}
export interface CircuitDeviceRowStructureProjectCommandStore {
execute(
input: ExecuteCircuitDeviceRowStructureCommandInput
): ExecutedCircuitDeviceRowStructureCommand;
}
@@ -3,6 +3,12 @@ import {
assertCircuitDeviceRowUpdateProjectCommand,
circuitDeviceRowUpdateCommandType,
} from "../models/circuit-device-row-project-command.model.js";
import {
assertCircuitDeviceRowDeleteProjectCommand,
assertCircuitDeviceRowInsertProjectCommand,
circuitDeviceRowDeleteCommandType,
circuitDeviceRowInsertCommandType,
} from "../models/circuit-device-row-structure-project-command.model.js";
import {
assertCircuitUpdateProjectCommand,
circuitUpdateCommandType,
@@ -12,6 +18,7 @@ import {
type SerializedProjectCommand,
} from "../models/project-command.model.js";
import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js";
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
import type {
ExecuteProjectHistoryCommandInput,
@@ -39,6 +46,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
constructor(
private readonly circuitStore: CircuitProjectCommandStore,
private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore,
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -118,6 +126,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case circuitDeviceRowInsertCommandType: {
assertCircuitDeviceRowInsertProjectCommand(input.command);
return this.deviceRowStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitDeviceRowDeleteCommandType: {
assertCircuitDeviceRowDeleteProjectCommand(input.command);
return this.deviceRowStructureStore.execute({
...input,
command: input.command,
}).revision;
}
default:
throw new Error(
`Unsupported project command type: ${input.command.type}.`
@@ -1,5 +1,6 @@
import { db } from "../../db/client.js";
import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-project-command.repository.js";
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
@@ -7,9 +8,12 @@ import { ProjectCommandService } from "../../domain/services/project-command.ser
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
export const circuitDeviceRowProjectCommandStore =
new CircuitDeviceRowProjectCommandRepository(db);
export const circuitDeviceRowStructureProjectCommandStore =
new CircuitDeviceRowStructureProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore,
circuitDeviceRowStructureProjectCommandStore,
projectHistoryStore
);
@@ -0,0 +1,445 @@
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 { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { 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 {
createCircuitDeviceRowDeleteProjectCommand,
createCircuitDeviceRowInsertProjectCommand,
type CircuitDeviceRowSnapshot,
} from "../src/domain/models/circuit-device-row-structure-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,
isReserve: 0,
},
{
id: "circuit-reserve",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F2",
sortOrder: 20,
isReserve: 1,
},
{
id: "circuit-foreign",
circuitListId: foreignBoard.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 1,
},
])
.run();
context.db
.insert(projectDevices)
.values([
{
id: "device-1",
projectId: "project-1",
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
id: "device-foreign",
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",
roomNumber: "001",
roomName: "Büro",
},
{
id: "room-foreign",
projectId: "project-2",
roomNumber: "999",
roomName: "Fremdraum",
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "device-1",
legacyConsumerId: "legacy-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte lokal",
phaseType: "single_phase",
connectionKind: "fixed",
costGroup: "440",
category: "lighting",
level: "EG",
roomId: "room-1",
roomNumberSnapshot: "001",
roomNameSnapshot: "Büro",
quantity: 2,
powerPerUnit: 0.12,
simultaneityFactor: 0.8,
cosPhi: 0.9,
remark: "Bestand",
overriddenFields: "[\"displayName\"]",
})
.run();
return context;
}
function createInsertSnapshot(
overrides: Partial<CircuitDeviceRowSnapshot> = {}
): CircuitDeviceRowSnapshot {
return {
id: "row-new",
circuitId: "circuit-reserve",
linkedProjectDeviceId: "device-1",
legacyConsumerId: null,
sortOrder: 10,
name: "Steckdose",
displayName: "Steckdose lokal",
phaseType: "single_phase",
connectionKind: "socket",
costGroup: "440",
category: "socket",
level: "EG",
roomId: "room-1",
roomNumberSnapshot: "001",
roomNameSnapshot: "Büro",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
cosPhi: 0.95,
remark: null,
overriddenFields: null,
...overrides,
};
}
function getIsReserve(context: DatabaseContext, circuitId: string) {
return Boolean(
context.db
.select({ isReserve: circuits.isReserve })
.from(circuits)
.where(eq(circuits.id, circuitId))
.get()?.isReserve
);
}
describe("circuit device-row structure project-command repository", () => {
it("inserts a stable row and supports persisted undo and redo", () => {
const context = createTestDatabase();
try {
const store =
new CircuitDeviceRowStructureProjectCommandRepository(context.db);
const command = createCircuitDeviceRowInsertProjectCommand(
createInsertSnapshot()
);
const inserted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-new"))
.get()?.displayName,
"Steckdose lokal"
);
assert.equal(getIsReserve(context, "circuit-reserve"), false);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-new"))
.get(),
undefined
);
assert.equal(getIsReserve(context, "circuit-reserve"), true);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command,
});
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-new"))
.get()?.id,
"row-new"
);
assert.deepEqual(
new ProjectHistoryRepository(context.db).getState("project-1"),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: inserted.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
context.close();
}
});
it("deletes and restores the complete persisted row snapshot", () => {
const context = createTestDatabase();
try {
const store =
new CircuitDeviceRowStructureProjectCommandRepository(context.db);
const before = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get();
assert.ok(before);
const deleted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowDeleteProjectCommand(
"row-1",
"circuit-1"
),
});
assert.equal(getIsReserve(context, "circuit-1"), true);
assert.deepEqual(deleted.inverse.payload, { row: before });
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: deleted.revision.changeSetId,
command: deleted.inverse,
});
assert.deepEqual(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get(),
before
);
assert.equal(getIsReserve(context, "circuit-1"), false);
} finally {
context.close();
}
});
it("rolls back insert and delete when late history persistence fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_structure_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced structure history failure');
END;
`);
const store =
new CircuitDeviceRowStructureProjectCommandRepository(context.db);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowInsertProjectCommand(
createInsertSnapshot()
),
}),
/forced structure history failure/
);
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-new"))
.get(),
undefined
);
assert.equal(getIsReserve(context, "circuit-reserve"), true);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowDeleteProjectCommand(
"row-1",
"circuit-1"
),
}),
/forced structure history failure/
);
assert.ok(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()
);
assert.equal(getIsReserve(context, "circuit-1"), false);
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
} finally {
context.close();
}
});
it("rolls back a structural write for a stale expected revision", () => {
const context = createTestDatabase();
try {
const store =
new CircuitDeviceRowStructureProjectCommandRepository(context.db);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitDeviceRowDeleteProjectCommand(
"row-1",
"circuit-1"
),
}),
/at revision 0, expected 1/
);
assert.ok(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()
);
assert.equal(getIsReserve(context, "circuit-1"), false);
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
} finally {
context.close();
}
});
it("rejects foreign ownership, references and duplicate row ids", () => {
const context = createTestDatabase();
try {
const store =
new CircuitDeviceRowStructureProjectCommandRepository(context.db);
for (const snapshot of [
createInsertSnapshot({ circuitId: "circuit-foreign" }),
createInsertSnapshot({
id: "row-foreign-device",
linkedProjectDeviceId: "device-foreign",
}),
createInsertSnapshot({
id: "row-foreign-room",
roomId: "room-foreign",
}),
createInsertSnapshot({ id: "row-1" }),
createInsertSnapshot({
id: "row-legacy",
legacyConsumerId: "legacy-user-value",
}),
]) {
assert.throws(() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowInsertProjectCommand(snapshot),
})
);
}
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
} finally {
context.close();
}
});
});
+74
View File
@@ -13,6 +13,11 @@ import {
assertCircuitDeviceRowUpdateProjectCommand,
createCircuitDeviceRowUpdateProjectCommand,
} from "../src/domain/models/circuit-device-row-project-command.model.js";
import {
assertCircuitDeviceRowInsertProjectCommand,
createCircuitDeviceRowDeleteProjectCommand,
createCircuitDeviceRowInsertProjectCommand,
} from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
describe("serialized project commands", () => {
it("round-trips a versioned command envelope", () => {
@@ -166,3 +171,72 @@ describe("circuit device-row update project commands", () => {
);
});
});
describe("circuit device-row structure project commands", () => {
const row = {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "lighting",
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: 0.9,
remark: null,
overriddenFields: null,
};
it("captures complete insert and delete identities", () => {
const insert = createCircuitDeviceRowInsertProjectCommand(row);
const remove = createCircuitDeviceRowDeleteProjectCommand(
row.id,
row.circuitId
);
assert.deepEqual(insert.payload.row, row);
assert.deepEqual(remove.payload, {
rowId: "row-1",
expectedCircuitId: "circuit-1",
});
});
it("rejects incomplete snapshots and invalid values", () => {
assert.throws(
() =>
assertCircuitDeviceRowInsertProjectCommand({
schemaVersion: 1,
type: "circuit-device-row.insert",
payload: {
row: {
...row,
linkedProjectDeviceId: undefined,
},
},
}),
/linkedProjectDeviceId/
);
assert.throws(
() =>
createCircuitDeviceRowInsertProjectCommand({
...row,
quantity: -1,
}),
/must not be negative/
);
assert.throws(
() => createCircuitDeviceRowDeleteProjectCommand("", "circuit-1"),
/rowId/
);
});
});
+62
View File
@@ -8,6 +8,7 @@ import {
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
@@ -20,6 +21,7 @@ import { projects } from "../src/db/schema/projects.js";
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.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 { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
@@ -72,6 +74,7 @@ function createService(context: DatabaseContext) {
return new ProjectCommandService(
new CircuitProjectCommandRepository(context.db),
new CircuitDeviceRowProjectCommandRepository(context.db),
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db)
);
}
@@ -193,6 +196,65 @@ describe("project command service", () => {
}
});
it("dispatches device-row insertion and its persisted inverse", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const inserted = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitDeviceRowInsertProjectCommand({
id: "row-2",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 20,
name: "Steckdose",
displayName: "Steckdose",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: null,
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
overriddenFields: null,
}),
});
assert.equal(inserted.history.undoDepth, 1);
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-2"))
.get()?.displayName,
"Steckdose"
);
const undone = createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(undone.history.redoDepth, 1);
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-2"))
.get(),
undefined
);
} finally {
context.close();
}
});
it("rejects unavailable history directions without writing a revision", () => {
const context = createTestDatabase();
try {