Add project device structure history

This commit is contained in:
2026-07-24 09:09:59 +02:00
parent 0bc6c7372f
commit ac16cca77a
15 changed files with 1262 additions and 26 deletions
+3
View File
@@ -236,6 +236,9 @@ by sorting or moving. Project-device synchronization, disconnect and reconnect
are persisted as one atomic multi-row command with complete expected/target row
snapshots, including link and override metadata. Canonical ProjectDevice field
updates are also persisted and never synchronize linked rows implicitly.
ProjectDevice insertion/deletion preserves stable device ids. Deletion captures
complete disconnected snapshots of linked rows so undo can restore only rows
that have remained unchanged.
Required operations:
+16 -8
View File
@@ -28,14 +28,15 @@ The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit-device-row.insert`, `circuit-device-row.delete`,
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
`circuit.reorder-section`, `circuit.renumber-section` and
`project-device.update` and `project-device.sync-rows`, all with schema version
`1`. Other command types are rejected. Insert commands contain the complete
entity or circuit block with stable ids; delete commands include the expected
parent identity. Circuit snapshots contain zero, one or multiple complete
device rows. Move commands contain each row's expected and target circuit plus
its exact expected and target sort order. This makes deletion and moves
undoable without generating replacement identities or renumbering circuits.
The editor does not consume these endpoints yet.
`project-device.update`, `project-device.insert`, `project-device.delete` and
`project-device.sync-rows`, all with schema version `1`. Other command types
are rejected. Insert commands contain the complete entity or circuit block
with stable ids; delete commands include the expected parent identity. Circuit
snapshots contain zero, one or multiple complete device rows. Move commands
contain each row's expected and target circuit plus its exact expected and
target sort order. This makes deletion and moves undoable without generating
replacement identities or renumbering circuits. The editor does not consume
these endpoints yet.
`circuit-device-row.move` targets existing circuits in the same circuit list.
`circuit-device-row.move-with-new-circuit` atomically creates exactly one
@@ -68,6 +69,13 @@ and derives its inverse from the persisted device. It validates project
ownership and never writes linked `CircuitDeviceRow` values; synchronization
remains a separate explicit command.
`project-device.insert` stores the complete canonical device with a stable id.
User inserts cannot attach existing rows. `project-device.delete` captures the
complete device and every currently linked row before deletion. Its inverse
recreates the same device and reconnects only rows whose complete disconnected
snapshot still matches; device, links, revision and history transition are
atomic.
Example:
```json
+8
View File
@@ -117,6 +117,14 @@ 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.
`project-device.insert` und `project-device.delete` versionieren außerdem den
Lebenszyklus eines Projektgeräts mit stabiler UUID. Beim Löschen speichert das
inverse Insert den vollständigen Gerätestand sowie vollständige, nach dem
Löschen erwartete Snapshots aller zuvor verknüpften Gerätezeilen. Undo setzt
die Links nur zurück, wenn diese Zeilen weiterhin zum Projekt gehören,
unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revision
und Historienstapel teilen dieselbe Transaktion. Die bestehenden Create-,
Import- und Delete-Endpunkte sind noch nicht umgestellt.
## Projektgeräte
@@ -197,13 +197,17 @@ Completed foundation:
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
- `project-device.insert` and `project-device.delete` preserve stable device
ids; delete undo restores links only from complete unchanged disconnected-row
snapshots in the same transaction
Remaining constraints before completing persistent history:
- extend the concrete project-scoped command union and executors beyond the
Circuit field/insert/delete and CircuitDeviceRow
field/insert/delete/move, section-reorder/renumber, ProjectDevice field-update
and row-sync slices; the generic versioned command envelope is complete
field/insert/delete/move, section-reorder/renumber and ProjectDevice
field/insert/delete/row-sync slices; the generic versioned command envelope
is complete
- route the remaining structural and destructive domain
writes through the shared command, revision and stack transaction boundary
- replace the session-local frontend command stack only after server-side
@@ -411,6 +411,8 @@ Implemented foundation:
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
- ProjectDevice insert/delete commands preserve stable device ids and restore
previously linked rows only from complete unchanged disconnected snapshots
- 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/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-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": "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-structure-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-project-command.repository.test.ts tests/project-device-structure-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:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts",
@@ -0,0 +1,339 @@
import {
and,
asc,
eq,
inArray,
isNull,
} from "drizzle-orm";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import {
assertProjectDeviceDeleteProjectCommand,
assertProjectDeviceInsertProjectCommand,
createProjectDeviceDeleteProjectCommand,
createProjectDeviceInsertProjectCommand,
projectDeviceDeleteCommandType,
projectDeviceInsertCommandType,
type ProjectDeviceSnapshot,
type ProjectDeviceStructureProjectCommand,
} from "../../domain/models/project-device-structure-project-command.model.js";
import type {
ExecuteProjectDeviceStructureCommandInput,
ProjectDeviceStructureProjectCommandStore,
} from "../../domain/ports/project-device-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 { projects } from "../schema/projects.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
const circuitDeviceRowSnapshotFields = [
"id",
"circuitId",
"linkedProjectDeviceId",
"legacyConsumerId",
"sortOrder",
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"level",
"roomId",
"roomNumberSnapshot",
"roomNameSnapshot",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"overriddenFields",
] as const satisfies readonly (keyof CircuitDeviceRowSnapshot)[];
export class ProjectDeviceStructureProjectCommandRepository
implements ProjectDeviceStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectDeviceStructureCommandInput) {
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: ExecuteProjectDeviceStructureCommandInput["source"],
command: ProjectDeviceStructureProjectCommand
): ProjectDeviceStructureProjectCommand {
if (command.type === projectDeviceInsertCommandType) {
assertProjectDeviceInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
command.payload.projectDevice,
command.payload.linkedRows
);
}
if (command.type === projectDeviceDeleteCommandType) {
assertProjectDeviceDeleteProjectCommand(command);
return this.delete(
database,
projectId,
command.payload.projectDeviceId,
command.payload.expectedProjectId
);
}
throw new Error("Unsupported project-device structure command.");
}
private insert(
database: AppDatabase,
projectId: string,
source: ExecuteProjectDeviceStructureCommandInput["source"],
snapshot: ProjectDeviceSnapshot,
linkedRows: CircuitDeviceRowSnapshot[]
) {
if (snapshot.projectId !== projectId) {
throw new Error(
"Project-device snapshot does not belong to project."
);
}
if (source === "user" && linkedRows.length > 0) {
throw new Error(
"User project-device inserts cannot reconnect existing rows."
);
}
const project = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project does not exist.");
}
const existing = database
.select({ id: projectDevices.id })
.from(projectDevices)
.where(eq(projectDevices.id, snapshot.id))
.get();
if (existing) {
throw new Error("Project-device id already exists.");
}
this.assertRestorableRows(database, projectId, linkedRows);
database.insert(projectDevices).values(snapshot).run();
for (const row of linkedRows) {
const updated = database
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: snapshot.id })
.where(
and(
eq(circuitDeviceRows.id, row.id),
isNull(circuitDeviceRows.linkedProjectDeviceId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"A restored project-device row changed during insertion."
);
}
}
return createProjectDeviceDeleteProjectCommand(
snapshot.id,
projectId
);
}
private delete(
database: AppDatabase,
projectId: string,
projectDeviceId: string,
expectedProjectId: string
) {
if (expectedProjectId !== projectId) {
throw new Error(
"Project-device delete project does not match command project."
);
}
const projectDevice = database
.select()
.from(projectDevices)
.where(
and(
eq(projectDevices.id, projectDeviceId),
eq(projectDevices.projectId, projectId)
)
)
.get();
if (!projectDevice) {
throw new Error(
"Project device does not belong to project."
);
}
const linkedRows = database
.select({
row: circuitDeviceRows,
projectId: circuitLists.projectId,
})
.from(circuitDeviceRows)
.innerJoin(
circuits,
eq(circuits.id, circuitDeviceRows.circuitId)
)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(
eq(
circuitDeviceRows.linkedProjectDeviceId,
projectDevice.id
)
)
.orderBy(
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
if (
linkedRows.some((linked) => linked.projectId !== projectId)
) {
throw new Error(
"Project device has linked rows outside its project."
);
}
const inverse = createProjectDeviceInsertProjectCommand(
toProjectDeviceSnapshot(projectDevice),
linkedRows.map(({ row }) => ({
...toCircuitDeviceRowSnapshot(row),
linkedProjectDeviceId: null,
}))
);
const deleted = database
.delete(projectDevices)
.where(
and(
eq(projectDevices.id, projectDevice.id),
eq(projectDevices.projectId, projectId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Project device could not be deleted.");
}
return inverse;
}
private assertRestorableRows(
database: AppDatabase,
projectId: string,
snapshots: CircuitDeviceRowSnapshot[]
) {
if (snapshots.length === 0) {
return;
}
const rowIds = snapshots.map((row) => row.id);
const persistedRows = database
.select({
row: circuitDeviceRows,
projectId: circuitLists.projectId,
})
.from(circuitDeviceRows)
.innerJoin(
circuits,
eq(circuits.id, circuitDeviceRows.circuitId)
)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (
persistedRows.length !== snapshots.length ||
persistedRows.some(
(persisted) => persisted.projectId !== projectId
)
) {
throw new Error(
"One or more restored rows do not belong to project."
);
}
const persistedById = new Map(
persistedRows.map(({ row }) => [
row.id,
toCircuitDeviceRowSnapshot(row),
])
);
for (const snapshot of snapshots) {
const persisted = persistedById.get(snapshot.id);
if (
!persisted ||
!circuitDeviceRowSnapshotFields.every(
(field) => persisted[field] === snapshot[field]
)
) {
throw new Error(
"A restored project-device row changed before insertion."
);
}
}
}
}
function toProjectDeviceSnapshot(
projectDevice: typeof projectDevices.$inferSelect
): ProjectDeviceSnapshot {
if (
projectDevice.phaseType !== "single_phase" &&
projectDevice.phaseType !== "three_phase"
) {
throw new Error("Persisted project-device phase type is invalid.");
}
return {
id: projectDevice.id,
projectId: projectDevice.projectId,
name: projectDevice.name,
displayName: projectDevice.displayName,
phaseType: projectDevice.phaseType,
connectionKind: projectDevice.connectionKind,
costGroup: projectDevice.costGroup,
category: projectDevice.category,
quantity: projectDevice.quantity,
powerPerUnit: projectDevice.powerPerUnit,
simultaneityFactor: projectDevice.simultaneityFactor,
cosPhi: projectDevice.cosPhi,
remark: projectDevice.remark,
voltageV: projectDevice.voltageV,
};
}
@@ -24,6 +24,21 @@ export type ProjectDeviceUpdateField =
export type ProjectDeviceUpdatePatch =
Partial<ProjectDeviceUpdateValues>;
export const projectDeviceUpdateFields = [
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"voltageV",
] as const satisfies readonly ProjectDeviceUpdateField[];
export interface ProjectDeviceUpdateFieldChange<
TField extends ProjectDeviceUpdateField = ProjectDeviceUpdateField,
> {
@@ -173,20 +188,9 @@ function assertProjectDeviceUpdateFieldValue(
function isProjectDeviceUpdateField(
value: string
): value is ProjectDeviceUpdateField {
return [
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"voltageV",
].includes(value);
return projectDeviceUpdateFields.includes(
value as ProjectDeviceUpdateField
);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -0,0 +1,171 @@
import {
assertCircuitDeviceRowInsertProjectCommand,
circuitDeviceRowInsertCommandType,
circuitDeviceRowStructureCommandSchemaVersion,
type CircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure-project-command.model.js";
import {
assertProjectDeviceUpdateProjectCommand,
projectDeviceUpdateCommandSchemaVersion,
projectDeviceUpdateCommandType,
projectDeviceUpdateFields,
type ProjectDeviceUpdateValues,
} from "./project-device-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectDeviceInsertCommandType =
"project-device.insert" as const;
export const projectDeviceDeleteCommandType =
"project-device.delete" as const;
export const projectDeviceStructureCommandSchemaVersion = 1 as const;
export interface ProjectDeviceSnapshot
extends ProjectDeviceUpdateValues {
id: string;
projectId: string;
}
export interface ProjectDeviceInsertCommandPayload {
projectDevice: ProjectDeviceSnapshot;
linkedRows: CircuitDeviceRowSnapshot[];
}
export interface ProjectDeviceDeleteCommandPayload {
projectDeviceId: string;
expectedProjectId: string;
}
export interface ProjectDeviceInsertProjectCommand
extends SerializedProjectCommand<ProjectDeviceInsertCommandPayload> {
schemaVersion: typeof projectDeviceStructureCommandSchemaVersion;
type: typeof projectDeviceInsertCommandType;
}
export interface ProjectDeviceDeleteProjectCommand
extends SerializedProjectCommand<ProjectDeviceDeleteCommandPayload> {
schemaVersion: typeof projectDeviceStructureCommandSchemaVersion;
type: typeof projectDeviceDeleteCommandType;
}
export type ProjectDeviceStructureProjectCommand =
| ProjectDeviceInsertProjectCommand
| ProjectDeviceDeleteProjectCommand;
export function createProjectDeviceInsertProjectCommand(
projectDevice: ProjectDeviceSnapshot,
linkedRows: CircuitDeviceRowSnapshot[] = []
): ProjectDeviceInsertProjectCommand {
const command: ProjectDeviceInsertProjectCommand = {
schemaVersion: projectDeviceStructureCommandSchemaVersion,
type: projectDeviceInsertCommandType,
payload: { projectDevice, linkedRows },
};
assertProjectDeviceInsertProjectCommand(command);
return command;
}
export function createProjectDeviceDeleteProjectCommand(
projectDeviceId: string,
expectedProjectId: string
): ProjectDeviceDeleteProjectCommand {
const command: ProjectDeviceDeleteProjectCommand = {
schemaVersion: projectDeviceStructureCommandSchemaVersion,
type: projectDeviceDeleteCommandType,
payload: { projectDeviceId, expectedProjectId },
};
assertProjectDeviceDeleteProjectCommand(command);
return command;
}
export function assertProjectDeviceInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectDeviceInsertProjectCommand {
if (
command.schemaVersion !== projectDeviceStructureCommandSchemaVersion ||
command.type !== projectDeviceInsertCommandType
) {
throw new Error("Unsupported project-device insert command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Project-device insert payload must be an object.");
}
const { projectDevice, linkedRows } = command.payload;
if (!isPlainObject(projectDevice)) {
throw new Error(
"Project-device insert command requires a project device."
);
}
assertNonEmptyString(projectDevice.id, "projectDevice.id");
assertNonEmptyString(
projectDevice.projectId,
"projectDevice.projectId"
);
assertProjectDeviceUpdateProjectCommand({
schemaVersion: projectDeviceUpdateCommandSchemaVersion,
type: projectDeviceUpdateCommandType,
payload: {
projectDeviceId: projectDevice.id,
changes: projectDeviceUpdateFields.map((field) => ({
field,
value: projectDevice[field],
})),
},
});
if (!Array.isArray(linkedRows)) {
throw new Error(
"Project-device insert linkedRows must be an array."
);
}
const rowIds = new Set<string>();
for (const row of linkedRows) {
assertCircuitDeviceRowInsertProjectCommand({
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType,
payload: { row },
});
if (row.linkedProjectDeviceId !== null) {
throw new Error(
"Restored project-device rows must currently be disconnected."
);
}
if (rowIds.has(row.id)) {
throw new Error(
"Project-device insert command contains duplicate row ids."
);
}
rowIds.add(row.id);
}
}
export function assertProjectDeviceDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectDeviceDeleteProjectCommand {
if (
command.schemaVersion !== projectDeviceStructureCommandSchemaVersion ||
command.type !== projectDeviceDeleteCommandType
) {
throw new Error("Unsupported project-device delete command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Project-device delete payload must be an object.");
}
assertNonEmptyString(
command.payload.projectDeviceId,
"projectDeviceId"
);
assertNonEmptyString(
command.payload.expectedProjectId,
"expectedProjectId"
);
}
function assertNonEmptyString(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,26 @@
import type { ProjectDeviceStructureProjectCommand } from "../models/project-device-structure-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteProjectDeviceStructureCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ProjectDeviceStructureProjectCommand;
}
export interface ExecutedProjectDeviceStructureCommand {
revision: AppendedProjectRevision;
inverse: ProjectDeviceStructureProjectCommand;
}
export interface ProjectDeviceStructureProjectCommandStore {
execute(
input: ExecuteProjectDeviceStructureCommandInput
): ExecutedProjectDeviceStructureCommand;
}
@@ -45,6 +45,12 @@ import {
assertProjectDeviceUpdateProjectCommand,
projectDeviceUpdateCommandType,
} from "../models/project-device-project-command.model.js";
import {
assertProjectDeviceDeleteProjectCommand,
assertProjectDeviceInsertProjectCommand,
projectDeviceDeleteCommandType,
projectDeviceInsertCommandType,
} from "../models/project-device-structure-project-command.model.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 { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
@@ -64,6 +70,7 @@ import type {
} 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 { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.store.js";
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
interface DispatchProjectCommandInput {
@@ -85,6 +92,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
@@ -238,6 +246,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case projectDeviceInsertCommandType: {
assertProjectDeviceInsertProjectCommand(input.command);
return this.projectDeviceStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case projectDeviceDeleteCommandType: {
assertProjectDeviceDeleteProjectCommand(input.command);
return this.projectDeviceStructureStore.execute({
...input,
command: input.command,
}).revision;
}
default:
throw new Error(
`Unsupported project command type: ${input.command.type}.`
@@ -9,6 +9,7 @@ import { CircuitStructureProjectCommandRepository } from "../../db/repositories/
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 { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
@@ -24,6 +25,8 @@ export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(db);
export const circuitSectionRenumberProjectCommandStore =
new CircuitSectionRenumberProjectCommandRepository(db);
export const projectDeviceStructureProjectCommandStore =
new ProjectDeviceStructureProjectCommandRepository(db);
export const projectDeviceProjectCommandStore =
new ProjectDeviceProjectCommandRepository(db);
export const projectDeviceRowSyncProjectCommandStore =
@@ -37,6 +40,7 @@ export const projectCommandService = new ProjectCommandService(
circuitStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore,
circuitSectionRenumberProjectCommandStore,
projectDeviceStructureProjectCommandStore,
projectDeviceProjectCommandStore,
projectDeviceRowSyncProjectCommandStore,
projectHistoryStore
+87
View File
@@ -46,6 +46,10 @@ import {
assertProjectDeviceUpdateProjectCommand,
createProjectDeviceUpdateProjectCommand,
} from "../src/domain/models/project-device-project-command.model.js";
import {
createProjectDeviceDeleteProjectCommand,
createProjectDeviceInsertProjectCommand,
} from "../src/domain/models/project-device-structure-project-command.model.js";
describe("serialized project commands", () => {
it("round-trips a versioned command envelope", () => {
@@ -353,6 +357,89 @@ describe("project-device update project commands", () => {
});
});
describe("project-device structure project commands", () => {
const projectDevice = {
id: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase" as const,
connectionKind: "fixed",
costGroup: "440",
category: "lighting",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
voltageV: 230,
};
const disconnectedRow = {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 10,
name: "Luminaire",
displayName: "Local lighting",
phaseType: "single_phase",
connectionKind: "fixed",
costGroup: "440",
category: "lighting",
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
overriddenFields: '["displayName"]',
};
it("captures complete device and disconnected-row restore snapshots", () => {
const insert = createProjectDeviceInsertProjectCommand(
projectDevice,
[disconnectedRow]
);
const remove = createProjectDeviceDeleteProjectCommand(
projectDevice.id,
projectDevice.projectId
);
assert.deepEqual(insert.payload, {
projectDevice,
linkedRows: [disconnectedRow],
});
assert.deepEqual(remove.payload, {
projectDeviceId: "project-device-1",
expectedProjectId: "project-1",
});
});
it("rejects invalid device values and rows that are already linked", () => {
assert.throws(
() =>
createProjectDeviceInsertProjectCommand({
...projectDevice,
quantity: -1,
}),
/outside its allowed range/
);
assert.throws(
() =>
createProjectDeviceInsertProjectCommand(projectDevice, [
{
...disconnectedRow,
linkedProjectDeviceId: "project-device-1",
},
]),
/must currently be disconnected/
);
});
});
describe("circuit device-row structure project commands", () => {
const row = {
id: "row-1",
+55
View File
@@ -18,6 +18,7 @@ import { DistributionBoardRepository } from "../src/db/repositories/distribution
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 { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.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";
@@ -39,6 +40,7 @@ import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models
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 { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext {
@@ -95,6 +97,7 @@ function createService(context: DatabaseContext) {
new CircuitStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectDeviceStructureProjectCommandRepository(context.db),
new ProjectDeviceProjectCommandRepository(context.db),
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db)
@@ -325,6 +328,58 @@ describe("project command service", () => {
}
});
it("dispatches project-device insertion and deletion", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const inserted = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createProjectDeviceInsertProjectCommand({
id: "project-device-new",
projectId: "project-1",
name: "Socket",
displayName: "Office socket",
phaseType: "single_phase",
connectionKind: null,
costGroup: "440",
category: "socket",
quantity: 2,
powerPerUnit: 0.2,
simultaneityFactor: 0.5,
cosPhi: null,
remark: null,
voltageV: 230,
}),
});
assert.equal(inserted.history.undoDepth, 1);
assert.equal(
context.db
.select()
.from(projectDevices)
.where(eq(projectDevices.id, "project-device-new"))
.get()?.displayName,
"Office socket"
);
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-new"))
.get(),
undefined
);
} finally {
context.close();
}
});
it("rejects stale undo and preserves the domain value and stack", () => {
const context = createTestDatabase();
try {
@@ -0,0 +1,503 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { asc, eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.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 {
createProjectDeviceDeleteProjectCommand,
createProjectDeviceInsertProjectCommand,
type ProjectDeviceSnapshot,
} from "../src/domain/models/project-device-structure-project-command.model.js";
function newProjectDeviceSnapshot(): ProjectDeviceSnapshot {
return {
id: "project-device-new",
projectId: "project-1",
name: "Socket",
displayName: "Office socket",
phaseType: "single_phase",
connectionKind: null,
costGroup: "440",
category: "socket",
quantity: 2,
powerPerUnit: 0.2,
simultaneityFactor: 0.5,
cosPhi: null,
remark: null,
voltageV: 230,
};
}
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(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();
context.db
.insert(circuits)
.values([
{
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
{
id: "circuit-foreign",
circuitListId: foreignBoard.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 10,
name: "Luminaire",
displayName: "Local row 1",
phaseType: "single_phase",
quantity: 1,
powerPerUnit: 0.03,
simultaneityFactor: 1,
overriddenFields: '["displayName","quantity"]',
},
{
id: "row-2",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 20,
name: "Luminaire",
displayName: "Local row 2",
phaseType: "single_phase",
quantity: 2,
powerPerUnit: 0.03,
simultaneityFactor: 1,
overriddenFields: '["displayName"]',
},
])
.run();
return context;
}
function getProjectDevice(
context: DatabaseContext,
id: string
) {
return context.db
.select()
.from(projectDevices)
.where(eq(projectDevices.id, id))
.get();
}
function getLinkedRowState(context: DatabaseContext) {
return context.db
.select({
id: circuitDeviceRows.id,
linkedProjectDeviceId:
circuitDeviceRows.linkedProjectDeviceId,
displayName: circuitDeviceRows.displayName,
quantity: circuitDeviceRows.quantity,
overriddenFields: circuitDeviceRows.overriddenFields,
})
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, "circuit-1"))
.orderBy(asc(circuitDeviceRows.id))
.all();
}
describe("project-device structure project-command repository", () => {
it("inserts a stable project device and supports undo and redo", () => {
const context = createTestDatabase();
try {
const store =
new ProjectDeviceStructureProjectCommandRepository(
context.db
);
const command = createProjectDeviceInsertProjectCommand(
newProjectDeviceSnapshot()
);
const inserted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(
getProjectDevice(context, "project-device-new")
?.displayName,
"Office socket"
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(
getProjectDevice(context, "project-device-new"),
undefined
);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId:
inserted.revision.changeSetId,
command,
});
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 a device and restores its exact linked rows", () => {
const context = createTestDatabase();
try {
const store =
new ProjectDeviceStructureProjectCommandRepository(
context.db
);
const beforeRows = getLinkedRowState(context);
const command = createProjectDeviceDeleteProjectCommand(
"project-device-1",
"project-1"
);
const deleted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(
getProjectDevice(context, "project-device-1"),
undefined
);
assert.deepEqual(
getLinkedRowState(context).map(
(row) => row.linkedProjectDeviceId
),
[null, null]
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
deleted.revision.changeSetId,
command: deleted.inverse,
});
assert.equal(
getProjectDevice(context, "project-device-1")
?.displayName,
"Office lighting"
);
assert.deepEqual(getLinkedRowState(context), beforeRows);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId:
deleted.revision.changeSetId,
command,
});
assert.deepEqual(
getLinkedRowState(context).map(
(row) => row.linkedProjectDeviceId
),
[null, null]
);
} finally {
context.close();
}
});
it("rejects unsafe user links, duplicate ids and foreign deletes", () => {
const context = createTestDatabase();
try {
const store =
new ProjectDeviceStructureProjectCommandRepository(
context.db
);
const disconnectedRow = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get();
assert.ok(disconnectedRow);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceInsertProjectCommand(
newProjectDeviceSnapshot(),
[
{
...disconnectedRow,
linkedProjectDeviceId: null,
},
]
),
}),
/cannot reconnect existing rows/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceInsertProjectCommand({
...newProjectDeviceSnapshot(),
id: "project-device-1",
}),
}),
/id already exists/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceDeleteProjectCommand(
"project-device-foreign",
"project-1"
),
}),
/does not belong to project/
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
it("refuses undo when a disconnected row changed", () => {
const context = createTestDatabase();
try {
const store =
new ProjectDeviceStructureProjectCommandRepository(
context.db
);
const deleted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceDeleteProjectCommand(
"project-device-1",
"project-1"
),
});
context.db
.update(circuitDeviceRows)
.set({ displayName: "Changed while disconnected" })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
deleted.revision.changeSetId,
command: deleted.inverse,
}),
/row changed before insertion/
);
assert.equal(
getProjectDevice(context, "project-device-1"),
undefined
);
assert.deepEqual(
getLinkedRowState(context).map(
(row) => row.linkedProjectDeviceId
),
[null, null]
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
1
);
} finally {
context.close();
}
});
it("rolls back deletion, disconnected links and history together", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_project_device_structure_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced project-device structure history failure');
END;
`);
const store =
new ProjectDeviceStructureProjectCommandRepository(
context.db
);
const beforeRows = getLinkedRowState(context);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceDeleteProjectCommand(
"project-device-1",
"project-1"
),
}),
/forced project-device structure history failure/
);
assert.equal(
getProjectDevice(context, "project-device-1")
?.displayName,
"Office lighting"
);
assert.deepEqual(getLinkedRowState(context), beforeRows);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
it("rolls back insertion for a stale project revision", () => {
const context = createTestDatabase();
try {
const store =
new ProjectDeviceStructureProjectCommandRepository(
context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createProjectDeviceInsertProjectCommand(
newProjectDeviceSnapshot()
),
}),
/at revision 0, expected 1/
);
assert.equal(
getProjectDevice(context, "project-device-new"),
undefined
);
} finally {
context.close();
}
});
});