Add project device update history

This commit is contained in:
2026-07-24 08:57:57 +02:00
parent 2668fc2f16
commit 0bc6c7372f
14 changed files with 785 additions and 13 deletions
+2 -1
View File
@@ -234,7 +234,8 @@ without changing equipment identifiers. Explicit complete-section renumbering
is persisted through a separate collision-safe command and is never triggered is persisted through a separate collision-safe command and is never triggered
by sorting or moving. Project-device synchronization, disconnect and reconnect by sorting or moving. Project-device synchronization, disconnect and reconnect
are persisted as one atomic multi-row command with complete expected/target row are persisted as one atomic multi-row command with complete expected/target row
snapshots, including link and override metadata. snapshots, including link and override metadata. Canonical ProjectDevice field
updates are also persisted and never synchronize linked rows implicitly.
Required operations: Required operations:
+13 -8
View File
@@ -28,14 +28,14 @@ The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit-device-row.insert`, `circuit-device-row.delete`, `circuit-device-row.insert`, `circuit-device-row.delete`,
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`, `circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
`circuit.reorder-section`, `circuit.renumber-section` and `circuit.reorder-section`, `circuit.renumber-section` and
`project-device.sync-rows`, all with schema version `1`. Other command types `project-device.update` and `project-device.sync-rows`, all with schema version
are rejected. Insert commands contain the complete entity or circuit block `1`. Other command types are rejected. Insert commands contain the complete
with stable ids; delete commands include the expected parent identity. Circuit entity or circuit block with stable ids; delete commands include the expected
snapshots contain zero, one or multiple complete device rows. Move commands parent identity. Circuit snapshots contain zero, one or multiple complete
contain each row's expected and target circuit plus its exact expected and device rows. Move commands contain each row's expected and target circuit plus
target sort order. This makes deletion and moves undoable without generating its exact expected and target sort order. This makes deletion and moves
replacement identities or renumbering circuits. The editor does not consume undoable without generating replacement identities or renumbering circuits.
these endpoints yet. The editor does not consume these endpoints yet.
`circuit-device-row.move` targets existing circuits in the same circuit list. `circuit-device-row.move` targets existing circuits in the same circuit list.
`circuit-device-row.move-with-new-circuit` atomically creates exactly one `circuit-device-row.move-with-new-circuit` atomically creates exactly one
@@ -63,6 +63,11 @@ All rows, the inverse command, revision and history-stack transition commit or
roll back together. Disconnect/reconnect may only change the link; stale row roll back together. Disconnect/reconnect may only change the link; stale row
snapshots and cross-project devices or rows are rejected. snapshots and cross-project devices or rows are rejected.
`project-device.update` changes one or multiple canonical ProjectDevice fields
and derives its inverse from the persisted device. It validates project
ownership and never writes linked `CircuitDeviceRow` values; synchronization
remains a separate explicit command.
Example: Example:
```json ```json
+6
View File
@@ -111,6 +111,12 @@ werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stell
exakt die vorherigen lokalen Werte wieder her. Die bestehenden exakt die vorherigen lokalen Werte wieder her. Die bestehenden
Projektgeräte-Endpunkte verwenden diesen Command bis zur Frontend-Umstellung Projektgeräte-Endpunkte verwenden diesen Command bis zur Frontend-Umstellung
noch nicht. noch nicht.
`project-device.update` versioniert Änderungen an den kanonischen
Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem
gespeicherten Gerät und schreibt Geräteänderung, Revision und Historienstapel
atomar. Verknüpfte Stromkreiszeilen werden dabei bewusst nicht automatisch
synchronisiert. Der bestehende ProjectDevice-`PUT`-Endpunkt verwendet diesen
Command bis zum UI-Cutover noch nicht.
## Projektgeräte ## Projektgeräte
@@ -195,13 +195,15 @@ Completed foundation:
- `project-device.sync-rows` records synchronization, disconnect and reconnect - `project-device.sync-rows` records synchronization, disconnect and reconnect
as one atomic multi-row operation; complete expected/target sync snapshots as one atomic multi-row operation; complete expected/target sync snapshots
protect local values, links and override metadata against silent stale writes protect local values, links and override metadata against silent stale writes
- `project-device.update` persists canonical source-device field changes
independently and never synchronizes linked rows implicitly
Remaining constraints before completing persistent history: Remaining constraints before completing persistent history:
- extend the concrete project-scoped command union and executors beyond the - extend the concrete project-scoped command union and executors beyond the
Circuit field/insert/delete and CircuitDeviceRow Circuit field/insert/delete and CircuitDeviceRow
field/insert/delete/move, section-reorder/renumber and ProjectDevice row-sync field/insert/delete/move, section-reorder/renumber, ProjectDevice field-update
slices; the generic versioned command envelope is complete and row-sync slices; the generic versioned command envelope is complete
- route the remaining structural and destructive domain - route the remaining structural and destructive domain
writes through the shared command, revision and stack transaction boundary writes through the shared command, revision and stack transaction boundary
- replace the session-local frontend command stack only after server-side - replace the session-local frontend command stack only after server-side
@@ -409,6 +409,8 @@ Implemented foundation:
- ProjectDevice row synchronization, disconnect and reconnect persist complete - ProjectDevice row synchronization, disconnect and reconnect persist complete
expected/target sync snapshots in one atomic multi-row command; undo restores expected/target sync snapshots in one atomic multi-row command; undo restores
local values, links and override metadata without silent overwrites local values, links and override metadata without silent overwrites
- canonical ProjectDevice field updates persist independently with exact
inverses and never overwrite linked CircuitDeviceRow values implicitly
- 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/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-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/circuit-device-row-move-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/project-device-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-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/circuit-device-row-move-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/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-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/circuit-device-row-move-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/project-device-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-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/circuit-device-row-move-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: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,108 @@
import { and, eq } from "drizzle-orm";
import {
assertProjectDeviceUpdateProjectCommand,
createProjectDeviceUpdateProjectCommand,
type ProjectDeviceUpdateField,
type ProjectDeviceUpdatePatch,
type ProjectDeviceUpdateValues,
} from "../../domain/models/project-device-project-command.model.js";
import type {
ExecuteProjectDeviceUpdateCommandInput,
ProjectDeviceProjectCommandStore,
} from "../../domain/ports/project-device-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectDevices } from "../schema/project-devices.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type ProjectDeviceRow = typeof projectDevices.$inferSelect;
export class ProjectDeviceProjectCommandRepository
implements ProjectDeviceProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) {
assertProjectDeviceUpdateProjectCommand(input.command);
return this.database.transaction((tx) => {
const current = tx
.select()
.from(projectDevices)
.where(
and(
eq(
projectDevices.id,
input.command.payload.projectDeviceId
),
eq(projectDevices.projectId, input.projectId)
)
)
.get();
if (!current) {
throw new Error(
"Project device does not belong to project."
);
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as ProjectDeviceUpdatePatch;
const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
getProjectDeviceFieldValue(current, change.field),
])
) as ProjectDeviceUpdatePatch;
const inverse = createProjectDeviceUpdateProjectCommand(
current.id,
inversePatch
);
const updated = tx
.update(projectDevices)
.set(patch)
.where(
and(
eq(projectDevices.id, current.id),
eq(projectDevices.projectId, input.projectId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Project device 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,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
}
function getProjectDeviceFieldValue<
TField extends ProjectDeviceUpdateField,
>(
projectDevice: ProjectDeviceRow,
field: TField
): ProjectDeviceUpdateValues[TField] {
return projectDevice[field] as ProjectDeviceUpdateValues[TField];
}
@@ -0,0 +1,194 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectDeviceUpdateCommandType =
"project-device.update" as const;
export const projectDeviceUpdateCommandSchemaVersion = 1 as const;
export interface ProjectDeviceUpdateValues {
name: string;
displayName: string;
phaseType: "single_phase" | "three_phase";
connectionKind: string | null;
costGroup: string | null;
category: string | null;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi: number | null;
remark: string | null;
voltageV: number | null;
}
export type ProjectDeviceUpdateField =
keyof ProjectDeviceUpdateValues;
export type ProjectDeviceUpdatePatch =
Partial<ProjectDeviceUpdateValues>;
export interface ProjectDeviceUpdateFieldChange<
TField extends ProjectDeviceUpdateField = ProjectDeviceUpdateField,
> {
field: TField;
value: ProjectDeviceUpdateValues[TField];
}
export interface ProjectDeviceUpdateCommandPayload {
projectDeviceId: string;
changes: ProjectDeviceUpdateFieldChange[];
}
export interface ProjectDeviceUpdateProjectCommand
extends SerializedProjectCommand<ProjectDeviceUpdateCommandPayload> {
schemaVersion: typeof projectDeviceUpdateCommandSchemaVersion;
type: typeof projectDeviceUpdateCommandType;
}
export function createProjectDeviceUpdateProjectCommand(
projectDeviceId: string,
patch: ProjectDeviceUpdatePatch
): ProjectDeviceUpdateProjectCommand {
const command: ProjectDeviceUpdateProjectCommand = {
schemaVersion: projectDeviceUpdateCommandSchemaVersion,
type: projectDeviceUpdateCommandType,
payload: {
projectDeviceId,
changes: Object.entries(patch).map(([field, value]) => ({
field: field as ProjectDeviceUpdateField,
value,
})) as ProjectDeviceUpdateFieldChange[],
},
};
assertProjectDeviceUpdateProjectCommand(command);
return command;
}
export function assertProjectDeviceUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectDeviceUpdateProjectCommand {
if (
command.schemaVersion !== projectDeviceUpdateCommandSchemaVersion ||
command.type !== projectDeviceUpdateCommandType
) {
throw new Error("Unsupported project-device update command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Project-device update payload must be an object.");
}
const { projectDeviceId, changes } = command.payload;
if (
typeof projectDeviceId !== "string" ||
!projectDeviceId.trim()
) {
throw new Error(
"Project-device update command requires a project device id."
);
}
if (!Array.isArray(changes) || changes.length === 0) {
throw new Error(
"Project-device 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(
"Project-device update command contains an invalid change."
);
}
if (
!isProjectDeviceUpdateField(change.field) ||
seenFields.has(change.field)
) {
throw new Error(
"Project-device update command contains an invalid or duplicate field."
);
}
assertProjectDeviceUpdateFieldValue(
change.field,
change.value
);
seenFields.add(change.field);
}
}
function assertProjectDeviceUpdateFieldValue(
field: ProjectDeviceUpdateField,
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 === "phaseType") {
if (value !== "single_phase" && value !== "three_phase") {
throw new Error("phaseType is invalid.");
}
return;
}
if (
field === "quantity" ||
field === "powerPerUnit" ||
field === "simultaneityFactor"
) {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < 0 ||
(field === "simultaneityFactor" && value > 1)
) {
throw new Error(`${field} is outside its allowed range.`);
}
return;
}
if (field === "cosPhi") {
if (
value !== null &&
(typeof value !== "number" ||
!Number.isFinite(value) ||
value < 0 ||
value > 1)
) {
throw new Error("cosPhi must be between zero and one or null.");
}
return;
}
if (field === "voltageV") {
if (
value !== null &&
(typeof value !== "number" ||
!Number.isFinite(value) ||
value <= 0)
) {
throw new Error("voltageV must be positive or null.");
}
return;
}
if (value !== null && typeof value !== "string") {
throw new Error(`${field} must be a string or null.`);
}
}
function isProjectDeviceUpdateField(
value: string
): value is ProjectDeviceUpdateField {
return [
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"voltageV",
].includes(value);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,26 @@
import type { ProjectDeviceUpdateProjectCommand } from "../models/project-device-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteProjectDeviceUpdateCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ProjectDeviceUpdateProjectCommand;
}
export interface ExecutedProjectDeviceUpdateCommand {
revision: AppendedProjectRevision;
inverse: ProjectDeviceUpdateProjectCommand;
}
export interface ProjectDeviceProjectCommandStore {
executeUpdate(
input: ExecuteProjectDeviceUpdateCommandInput
): ExecutedProjectDeviceUpdateCommand;
}
@@ -41,6 +41,10 @@ import {
assertProjectDeviceRowSyncProjectCommand, assertProjectDeviceRowSyncProjectCommand,
projectDeviceRowSyncCommandType, projectDeviceRowSyncCommandType,
} from "../models/project-device-row-sync-project-command.model.js"; } from "../models/project-device-row-sync-project-command.model.js";
import {
assertProjectDeviceUpdateProjectCommand,
projectDeviceUpdateCommandType,
} from "../models/project-device-project-command.model.js";
import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js"; import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js";
import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js"; import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js";
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js"; import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
@@ -58,6 +62,7 @@ import type {
ProjectHistoryDirection, ProjectHistoryDirection,
ProjectHistoryStore, ProjectHistoryStore,
} from "../ports/project-history.store.js"; } from "../ports/project-history.store.js";
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js"; import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
import type { ProjectRevisionSource } from "../ports/project-revision.store.js"; import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
@@ -80,6 +85,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitStructureStore: CircuitStructureProjectCommandStore, private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore, private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore, private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore, private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
private readonly historyStore: ProjectHistoryStore private readonly historyStore: ProjectHistoryStore
) {} ) {}
@@ -225,6 +231,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case projectDeviceUpdateCommandType: {
assertProjectDeviceUpdateProjectCommand(input.command);
return this.projectDeviceStore.executeUpdate({
...input,
command: input.command,
}).revision;
}
default: default:
throw new Error( throw new Error(
`Unsupported project command type: ${input.command.type}.` `Unsupported project command type: ${input.command.type}.`
@@ -7,6 +7,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../../db/reposito
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js";
@@ -23,6 +24,8 @@ export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(db); new CircuitSectionReorderProjectCommandRepository(db);
export const circuitSectionRenumberProjectCommandStore = export const circuitSectionRenumberProjectCommandStore =
new CircuitSectionRenumberProjectCommandRepository(db); new CircuitSectionRenumberProjectCommandRepository(db);
export const projectDeviceProjectCommandStore =
new ProjectDeviceProjectCommandRepository(db);
export const projectDeviceRowSyncProjectCommandStore = export const projectDeviceRowSyncProjectCommandStore =
new ProjectDeviceRowSyncProjectCommandRepository(db); new ProjectDeviceRowSyncProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db);
@@ -34,6 +37,7 @@ export const projectCommandService = new ProjectCommandService(
circuitStructureProjectCommandStore, circuitStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore, circuitSectionReorderProjectCommandStore,
circuitSectionRenumberProjectCommandStore, circuitSectionRenumberProjectCommandStore,
projectDeviceProjectCommandStore,
projectDeviceRowSyncProjectCommandStore, projectDeviceRowSyncProjectCommandStore,
projectHistoryStore projectHistoryStore
); );
+60
View File
@@ -42,6 +42,10 @@ import {
createProjectDeviceRowSyncProjectCommand, createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot, type ProjectDeviceSyncRowSnapshot,
} from "../src/domain/models/project-device-row-sync-project-command.model.js"; } from "../src/domain/models/project-device-row-sync-project-command.model.js";
import {
assertProjectDeviceUpdateProjectCommand,
createProjectDeviceUpdateProjectCommand,
} from "../src/domain/models/project-device-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", () => {
@@ -293,6 +297,62 @@ describe("project-device row sync project commands", () => {
}); });
}); });
describe("project-device update project commands", () => {
it("creates typed canonical field changes with null clearing", () => {
const command = createProjectDeviceUpdateProjectCommand(
"project-device-1",
{
displayName: "Flurbeleuchtung",
cosPhi: null,
voltageV: 230,
}
);
assert.doesNotThrow(() =>
assertProjectDeviceUpdateProjectCommand(command)
);
assert.deepEqual(command.payload.changes, [
{ field: "displayName", value: "Flurbeleuchtung" },
{ field: "cosPhi", value: null },
{ field: "voltageV", value: 230 },
]);
});
it("rejects empty, duplicate and invalid canonical values", () => {
assert.throws(
() =>
createProjectDeviceUpdateProjectCommand(
"project-device-1",
{}
),
/at least one change/
);
assert.throws(
() =>
assertProjectDeviceUpdateProjectCommand({
schemaVersion: 1,
type: "project-device.update",
payload: {
projectDeviceId: "project-device-1",
changes: [
{ field: "quantity", value: 1 },
{ field: "quantity", value: 2 },
],
},
}),
/duplicate field/
);
assert.throws(
() =>
createProjectDeviceUpdateProjectCommand(
"project-device-1",
{ simultaneityFactor: 1.1 }
),
/outside its allowed range/
);
});
});
describe("circuit device-row structure project commands", () => { describe("circuit device-row structure project commands", () => {
const row = { const row = {
id: "row-1", id: "row-1",
+59
View File
@@ -16,6 +16,7 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/reposi
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
@@ -37,6 +38,7 @@ import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js"; import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js"; import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js"; import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js";
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext { function createTestDatabase(): DatabaseContext {
@@ -93,6 +95,7 @@ function createService(context: DatabaseContext) {
new CircuitStructureProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectDeviceProjectCommandRepository(context.db),
new ProjectDeviceRowSyncProjectCommandRepository(context.db), new ProjectDeviceRowSyncProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db) new ProjectHistoryRepository(context.db)
); );
@@ -266,6 +269,62 @@ describe("project command service", () => {
} }
}); });
it("dispatches project-device updates and their persisted inverse", () => {
const context = createTestDatabase();
try {
context.db
.insert(projectDevices)
.values({
id: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
})
.run();
const service = createService(context);
const updated = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createProjectDeviceUpdateProjectCommand(
"project-device-1",
{
displayName: "Flurbeleuchtung",
powerPerUnit: 0.05,
}
),
});
assert.equal(updated.history.undoDepth, 1);
assert.equal(
context.db
.select()
.from(projectDevices)
.where(eq(projectDevices.id, "project-device-1"))
.get()?.displayName,
"Flurbeleuchtung"
);
const undone = createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(undone.history.redoDepth, 1);
assert.equal(
context.db
.select()
.from(projectDevices)
.where(eq(projectDevices.id, "project-device-1"))
.get()?.displayName,
"Office lighting"
);
} finally {
context.close();
}
});
it("rejects stale undo and preserves the domain value and stack", () => { it("rejects stale undo and preserves the domain value and stack", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
@@ -0,0 +1,292 @@
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 { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.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 { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-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();
context.db
.insert(projectDevices)
.values([
{
id: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
connectionKind: "fixed",
costGroup: "440",
category: "lighting",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
voltageV: 230,
},
{
id: "project-device-foreign",
projectId: "project-2",
name: "Foreign device",
displayName: "Foreign device",
phaseType: "three_phase",
quantity: 1,
powerPerUnit: 1,
simultaneityFactor: 1,
},
])
.run();
return context;
}
function getProjectDevice(
context: DatabaseContext,
projectDeviceId = "project-device-1"
) {
const device = context.db
.select()
.from(projectDevices)
.where(eq(projectDevices.id, projectDeviceId))
.get();
assert.ok(device);
return device;
}
describe("project-device project-command repository", () => {
it("updates canonical fields and supports persisted undo and redo", () => {
const context = createTestDatabase();
try {
const store = new ProjectDeviceProjectCommandRepository(
context.db
);
const command = createProjectDeviceUpdateProjectCommand(
"project-device-1",
{
displayName: "Flurbeleuchtung",
phaseType: "three_phase",
connectionKind: null,
powerPerUnit: 0.05,
cosPhi: null,
voltageV: 400,
}
);
const updated = store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
{
displayName: getProjectDevice(context).displayName,
phaseType: getProjectDevice(context).phaseType,
connectionKind: getProjectDevice(context).connectionKind,
powerPerUnit: getProjectDevice(context).powerPerUnit,
cosPhi: getProjectDevice(context).cosPhi,
voltageV: getProjectDevice(context).voltageV,
},
{
displayName: "Flurbeleuchtung",
phaseType: "three_phase",
connectionKind: null,
powerPerUnit: 0.05,
cosPhi: null,
voltageV: 400,
}
);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: updated.revision.changeSetId,
command: updated.inverse,
});
assert.deepEqual(
{
displayName: getProjectDevice(context).displayName,
phaseType: getProjectDevice(context).phaseType,
connectionKind: getProjectDevice(context).connectionKind,
powerPerUnit: getProjectDevice(context).powerPerUnit,
cosPhi: getProjectDevice(context).cosPhi,
voltageV: getProjectDevice(context).voltageV,
},
{
displayName: "Office lighting",
phaseType: "single_phase",
connectionKind: "fixed",
powerPerUnit: 0.04,
cosPhi: 0.95,
voltageV: 230,
}
);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: updated.revision.changeSetId,
command,
});
assert.deepEqual(
new ProjectHistoryRepository(context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: updated.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
context.close();
}
});
it("rejects project devices from another project", () => {
const context = createTestDatabase();
try {
const store = new ProjectDeviceProjectCommandRepository(
context.db
);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceUpdateProjectCommand(
"project-device-foreign",
{ displayName: "Not allowed" }
),
}),
/does not belong to project/
);
assert.equal(
getProjectDevice(
context,
"project-device-foreign"
).displayName,
"Foreign device"
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
it("rolls back the update for a stale project revision", () => {
const context = createTestDatabase();
try {
const store = new ProjectDeviceProjectCommandRepository(
context.db
);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceUpdateProjectCommand(
"project-device-1",
{ displayName: "First change" }
),
});
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceUpdateProjectCommand(
"project-device-1",
{ displayName: "Stale change" }
),
}),
(error) =>
error instanceof ProjectRevisionConflictError &&
error.actualRevision === 1
);
assert.equal(
getProjectDevice(context).displayName,
"First change"
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
1
);
} finally {
context.close();
}
});
it("rolls back the update after a late history failure", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_project_device_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced project-device history failure');
END;
`);
const store = new ProjectDeviceProjectCommandRepository(
context.db
);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceUpdateProjectCommand(
"project-device-1",
{
displayName: "Must roll back",
quantity: 9,
}
),
}),
/forced project-device history failure/
);
assert.equal(
getProjectDevice(context).displayName,
"Office lighting"
);
assert.equal(getProjectDevice(context).quantity, 4);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
});