Compare commits

...

10 Commits

Author SHA1 Message Date
jappel 53282e8c7c Add placeholder move history 2026-07-23 22:39:59 +02:00
jappel 0a51703315 Add device row move history 2026-07-23 22:29:44 +02:00
jappel ca13efbfcb Add circuit history commands 2026-07-23 22:18:36 +02:00
jappel bd5f4f925f Add device row history commands 2026-07-23 22:09:53 +02:00
jappel e4c7cf06e9 Add project command API 2026-07-23 21:56:13 +02:00
jappel 1e4dd26bb8 Add persistent history stacks 2026-07-23 21:48:24 +02:00
jappel 2d0aa11d9c Add atomic device row updates 2026-07-23 21:38:20 +02:00
jappel 296cb0f1c4 Add atomic circuit update commands 2026-07-23 21:32:25 +02:00
jappel b79faed320 Add serializable project commands 2026-07-23 21:23:31 +02:00
jappel 903d977443 Add project revision foundation 2026-07-23 21:17:48 +02:00
65 changed files with 11291 additions and 118 deletions
+10 -3
View File
@@ -220,8 +220,15 @@ After saving, the row becomes linked to the new project device.
## Undo / Redo
Session-local undo/redo exists for structural and destructive operations.
Persistent project-wide undo/redo remains future work.
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 as well as
Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and
the UI cutover remain future work. CircuitDeviceRow moves between existing
circuits and moves that create one new placeholder target circuit are also
persisted. The latter stores the complete empty target snapshot so undo can
restore the rows and remove only the unchanged generated circuit.
Required operations:
@@ -271,7 +278,7 @@ Users must be able to override sizing suggestions.
## Current Deferred Work
- persistent project revisions and undo/redo
- complete persistent project command coverage and the undo/redo UI cutover
- named logical snapshots and restore
- Revit/CSV/IFCGUID round-trip
- full electrical sizing
+61
View File
@@ -5,6 +5,67 @@
The circuit-first editor uses tree, circuit, row and project-device endpoints.
All paths below are mounted below `/api`. There is no Consumer application API.
Project responses from `GET /projects` and `GET /projects/:projectId` include
`currentRevision`. Versioned project commands use this value for optimistic
concurrency checks.
### Project Commands and History
- `GET /projects/:projectId/history`
- returns `currentRevision`, `undoDepth`, `redoDepth` and the current top
change-set id for both persistent stacks
- `POST /projects/:projectId/commands`
- executes a supported versioned command as a new user revision
- body: `{ "expectedRevision": 0, "command": { ... } }`
- `POST /projects/:projectId/history/undo`
- `POST /projects/:projectId/history/redo`
- body: `{ "expectedRevision": 1 }`
- executes the eligible inverse or forward command as a new auditable
revision
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit.delete`, `circuit-device-row.update`,
`circuit-device-row.insert`, `circuit-device-row.delete` and
`circuit-device-row.move` plus
`circuit-device-row.move-with-new-circuit`, 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
explicitly identified empty target circuit and moves one or multiple existing
rows into it. Its inverse restores every row to its exact prior position and
deletes the generated circuit only if its fields and complete row set still
match the recorded state.
Example:
```json
{
"expectedRevision": 0,
"description": "Rename circuit",
"command": {
"schemaVersion": 1,
"type": "circuit.update",
"payload": {
"circuitId": "cir_1",
"changes": [
{ "field": "displayName", "value": "Sockets East" }
]
}
}
}
```
A stale `expectedRevision` returns HTTP `409` with
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
## Circuit-First Endpoints
### Distribution Board Setup
+36 -3
View File
@@ -62,9 +62,42 @@ liegt über einen Host-Mount außerhalb des Containers.
6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise
Viewport soweit möglich wieder her.
Die React-Historie ist derzeit sitzungslokal. Persistente Projektrevisionen,
optimistische Revisionsprüfungen und serverseitiges Undo/Redo sind die nächste
Architekturphase.
Die React-Historie ist derzeit sitzungslokal. Das Datenmodell besitzt bereits
einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und
bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze
angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren interne
Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und
Revision gemeinsam committen beziehungsweise zurückrollen können.
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
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. 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. `circuit.insert` und `circuit.delete` behandeln einen
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
Betriebsmittelkennzeichen. Das Grid verwendet diese Endpunkte noch nicht; seine
sichtbare Historie bleibt daher sitzungslokal.
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
Reservewerte aller beteiligten Stromkreise werden atomar neu abgeleitet. Das
Kompositkommando `circuit-device-row.move-with-new-circuit` bildet auch das
Verschieben auf einen freien Platz ab: Ein Zielstromkreis mit stabiler UUID und
BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten
Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen
Felder und vollständiger Zeilenbestand unverändert sind. Circuit-Sortierung,
Neunummerierung und Synchronisierung müssen vor der Frontend-Umstellung noch
als persistente Commands modelliert werden.
## Projektgeräte
@@ -137,11 +137,67 @@ Completed foundation:
- database backup and independent restore verification are automated
- the Consumer application path is removed and stable domain IDs are preserved
- editor grid projection and safety rules are separated from React rendering
- projects carry a monotonic `currentRevision` counter starting at zero
- project revision metadata and versioned forward/inverse change-set payloads
have separate persistence entities
- forward and inverse payloads store complete serialized command envelopes;
command type and schema version are derived from those envelopes rather than
duplicated caller metadata
- command serialization rejects non-finite numbers, `undefined`, non-plain
objects and cyclic payloads before a revision transaction starts
- the SQLite revision repository advances the project counter and stores both
history records atomically
- revision appends use an expected-revision compare-and-set and reject stale
callers without writing partial history
- a first internal `circuit.update` command validates project/list/section
ownership and equipment-identifier uniqueness inside its transaction
- circuit updates derive their inverse from the persisted pre-command row and
commit the domain update, inverse command, change set and revision together
- an internal `circuit-device-row.update` command applies the same boundary to
device fields, validates project-device and room ownership and records
automatically derived local override metadata in its normalized forward
command
- integration tests apply the generated inverse as a new `undo` revision and
verify rollback for stale revisions and late history failures on both update
command types
- project-scoped relational undo/redo stacks persist eligible original change
sets independently from immutable audit revisions
- stack transitions enforce LIFO order, keep undo/redo revisions out of the
eligibility stacks and clear the redo branch on a new user command
- domain writes, audit revisions and stack transitions share one transaction;
a forced stack failure rolls all of them back
- `GET /api/projects/:projectId/history` exposes current revision, stack depths
and top change-set ids
- a central application dispatcher executes the supported `circuit.update`
and `circuit-device-row.update` envelopes without coupling the domain service
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
- `circuit.insert` and `circuit.delete` persist a complete circuit block with
zero, one or multiple device rows; undo restores all ids and fields without
renumbering
- `circuit-device-row.move` stores exact expected and target circuit/sort
positions for one or multiple rows, recalculates all affected reserve states
and reconstructs a deterministic inverse across multiple source circuits
- `circuit-device-row.move-with-new-circuit` additionally records the complete
empty target-circuit snapshot; forward creation and all row moves share one
transaction, while undo restores the source positions and deletes only an
unchanged generated target
Remaining constraints before implementing history:
Remaining constraints before completing persistent history:
- model project-scoped command descriptions independently from React callbacks
- route all future history-enabled mutations through one project revision boundary
- extend the concrete project-scoped command union and executors beyond the
Circuit field/insert/delete and CircuitDeviceRow
field/insert/delete/move 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
command coverage is complete for structural, destructive and synchronization
operations
- do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests
+53 -4
View File
@@ -354,14 +354,63 @@ Persist project-wide change history and restore points across reloads and applic
Tasks:
- add project-scoped monotonic revisions
- record immutable server-side change sets with before/after state
- add optimistic revision checks for stale commands
- persist undo/redo eligibility on the server
- [x] add project-scoped monotonic revision storage
- [x] add immutable append-only server-side change-set storage with versioned
forward/inverse payloads
- [x] add optimistic checks when appending a revision
- route domain writes and history records through one shared transaction
- define concrete serializable domain command descriptions and executors
outside React
- [x] persist undo/redo eligibility on the server
- [x] expose public command, undo and redo endpoints for the first supported
update command types
- add named and periodic logical project snapshots
- restore a historical snapshot as a new revision
- keep database backups separate from logical history
Implemented foundation:
- projects start at revision zero and advance monotonically
- each revision has one separately stored logical change set
- forward and inverse changes use complete, versioned JSON command envelopes
- invalid or lossy command payloads are rejected before persistence
- an internal typed `circuit.update` command generates its inverse from the
persisted pre-command state
- circuit update, inverse command, change set and revision share one SQLite
transaction with stale-command and late-failure rollback coverage
- an internal typed `circuit-device-row.update` command uses the same boundary,
records derived ProjectDevice override metadata and rejects cross-project
rows, device links and rooms
- relational project-wide undo/redo stacks persist eligible original change
sets, enforce LIFO order and clear redo on a new user command
- stack transitions are part of the domain/revision transaction and have
late-failure rollback coverage
- a read-only project history endpoint exposes revision, stack depths and top
change-set ids
- 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
- typed Circuit insert/delete commands treat the circuit and all device rows as
one block, preserve every stable id and never renumber neighbouring circuits
- typed CircuitDeviceRow move commands persist exact old/new circuit and sort
positions for single or multi-row moves between existing circuits and derive
affected reserve states in the same transaction
- placeholder-target moves persist one complete generated circuit identity and
all row positions atomically; undo restores every source and removes only an
unchanged generated circuit, without renumbering
- revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction
- a stale expected revision produces no history writes
- integration coverage verifies sequential revisions, stale-command rejection
and rollback after a forced late persistence failure
The current editor operations are not connected to this history boundary yet.
Undo/redo therefore remains session-local until the remaining tasks are
implemented.
Acceptance criteria:
- undo/redo remains available after a page reload
+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/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/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-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/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-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,24 @@
CREATE TABLE `project_change_sets` (
`id` text PRIMARY KEY NOT NULL,
`project_revision_id` text NOT NULL,
`command_type` text NOT NULL,
`payload_schema_version` integer NOT NULL,
`forward_payload_json` text NOT NULL,
`inverse_payload_json` text NOT NULL,
FOREIGN KEY (`project_revision_id`) REFERENCES `project_revisions`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_change_sets_revision_unique` ON `project_change_sets` (`project_revision_id`);--> statement-breakpoint
CREATE TABLE `project_revisions` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`revision_number` integer NOT NULL,
`created_at_iso` text NOT NULL,
`actor_id` text,
`source` text NOT NULL,
`description` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_revisions_project_number_unique` ON `project_revisions` (`project_id`,`revision_number`);--> statement-breakpoint
ALTER TABLE `projects` ADD `current_revision` integer DEFAULT 0 NOT NULL;
@@ -0,0 +1,11 @@
CREATE TABLE `project_history_stack_entries` (
`change_set_id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`stack` text NOT NULL,
`position` integer NOT NULL,
FOREIGN KEY (`change_set_id`) REFERENCES `project_change_sets`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "project_history_stack_entries_stack_check" CHECK("project_history_stack_entries"."stack" in ('undo', 'redo'))
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_history_stack_entries_position_unique` ON `project_history_stack_entries` (`project_id`,`stack`,`position`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -85,6 +85,20 @@
"when": 1784832541202,
"tag": "0011_project_device_canonical_fields",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1784833957929,
"tag": "0012_project_revision_foundation",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1784835703230,
"tag": "0013_project_history_stacks",
"breakpoints": true
}
]
}
@@ -0,0 +1,516 @@
import { and, eq, inArray } from "drizzle-orm";
import {
assertCircuitDeviceRowMoveProjectCommand,
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand,
circuitDeviceRowMoveCommandType,
circuitDeviceRowMoveWithNewCircuitCommandType,
createCircuitDeviceRowMoveProjectCommand,
createCircuitDeviceRowMoveWithNewCircuitProjectCommand,
type CircuitDeviceRowMoveAssignment,
type CircuitDeviceRowMoveHistoryProjectCommand,
} from "../../domain/models/circuit-device-row-move-project-command.model.js";
import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js";
import type {
CircuitDeviceRowMoveProjectCommandStore,
ExecuteCircuitDeviceRowMoveCommandInput,
} from "../../domain/ports/circuit-device-row-move-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 { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
interface PersistedMoveRow {
id: string;
circuitId: string;
sortOrder: number;
}
export class CircuitDeviceRowMoveProjectCommandRepository
implements CircuitDeviceRowMoveProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowMoveCommandInput) {
this.assertSupportedCommand(input.command);
return this.database.transaction((tx) => {
const inverse = this.applyCommand(
tx,
input.projectId,
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 assertSupportedCommand(
command: CircuitDeviceRowMoveHistoryProjectCommand
) {
if (command.type === circuitDeviceRowMoveCommandType) {
assertCircuitDeviceRowMoveProjectCommand(command);
return;
}
if (
command.type ===
circuitDeviceRowMoveWithNewCircuitCommandType
) {
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
command
);
return;
}
throw new Error("Unsupported circuit device-row move command.");
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: CircuitDeviceRowMoveHistoryProjectCommand
): CircuitDeviceRowMoveHistoryProjectCommand {
if (command.type === circuitDeviceRowMoveCommandType) {
const rowsById = this.loadExpectedRows(
database,
command.payload.moves
);
const circuitIds = this.collectCircuitIds(
command.payload.moves
);
this.assertExistingCircuitsInOneList(
database,
projectId,
circuitIds
);
const inverse = createCircuitDeviceRowMoveProjectCommand(
this.reverseMoves(command.payload.moves, rowsById)
);
this.applyMoves(database, command.payload.moves);
this.updateReserveStates(database, circuitIds);
return inverse;
}
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
command
);
const { targetCircuit, targetCircuitAction, moves } =
command.payload;
const rowsById = this.loadExpectedRows(database, moves);
this.assertCircuitLocation(database, projectId, targetCircuit);
if (targetCircuitAction === "create") {
const sourceCircuitIds = [
...new Set(moves.map((move) => move.expectedCircuitId)),
];
this.assertExistingCircuitsInOneList(
database,
projectId,
sourceCircuitIds,
targetCircuit.circuitListId
);
this.assertTargetCircuitAvailable(database, targetCircuit);
this.insertTargetCircuit(database, targetCircuit);
const inverse =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"delete",
targetCircuit,
this.reverseMoves(moves, rowsById)
);
this.applyMoves(database, moves);
this.updateReserveStates(database, [
...sourceCircuitIds,
targetCircuit.id,
]);
return inverse;
}
this.assertTargetCircuitUnchanged(
database,
targetCircuit,
moves.map((move) => move.rowId)
);
const destinationCircuitIds = [
...new Set(moves.map((move) => move.targetCircuitId)),
];
this.assertExistingCircuitsInOneList(
database,
projectId,
destinationCircuitIds,
targetCircuit.circuitListId
);
const inverse =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
targetCircuit,
this.reverseMoves(moves, rowsById)
);
this.applyMoves(database, moves);
this.updateReserveStates(database, [
targetCircuit.id,
...destinationCircuitIds,
]);
const deleted = database
.delete(circuits)
.where(
and(
eq(circuits.id, targetCircuit.id),
eq(
circuits.circuitListId,
targetCircuit.circuitListId
),
eq(circuits.isReserve, 1)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error(
"Created target circuit changed before deletion."
);
}
return inverse;
}
private loadExpectedRows(
database: AppDatabase,
moves: CircuitDeviceRowMoveAssignment[]
) {
const rowIds = moves.map((move) => move.rowId);
const persistedRows = database
.select({
id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId,
sortOrder: circuitDeviceRows.sortOrder,
})
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (persistedRows.length !== rowIds.length) {
throw new Error(
"One or more circuit device rows do not exist."
);
}
const rowsById = new Map(
persistedRows.map((row) => [row.id, row])
);
for (const move of moves) {
const row = rowsById.get(move.rowId);
if (
!row ||
row.circuitId !== move.expectedCircuitId ||
row.sortOrder !== move.expectedSortOrder
) {
throw new Error(
"Circuit device row changed before move execution."
);
}
}
return rowsById;
}
private collectCircuitIds(
moves: CircuitDeviceRowMoveAssignment[]
) {
return [
...new Set(
moves.flatMap((move) => [
move.expectedCircuitId,
move.targetCircuitId,
])
),
];
}
private assertExistingCircuitsInOneList(
database: AppDatabase,
projectId: string,
circuitIds: string[],
expectedCircuitListId?: string
) {
const participatingCircuits = database
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
projectId: circuitLists.projectId,
})
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(inArray(circuits.id, circuitIds))
.all();
if (participatingCircuits.length !== circuitIds.length) {
throw new Error(
"One or more move circuits do not exist."
);
}
if (
participatingCircuits.some(
(circuit) => circuit.projectId !== projectId
)
) {
throw new Error("Move circuit does not belong to project.");
}
const circuitListIds = new Set(
participatingCircuits.map(
(circuit) => circuit.circuitListId
)
);
if (
circuitListIds.size !== 1 ||
(expectedCircuitListId !== undefined &&
!circuitListIds.has(expectedCircuitListId))
) {
throw new Error(
"All moved rows and targets must belong to one circuit list."
);
}
}
private assertCircuitLocation(
database: AppDatabase,
projectId: string,
snapshot: CircuitSnapshot
) {
const list = database
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, snapshot.circuitListId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!list) {
throw new Error("Circuit list does not belong to project.");
}
const section = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(
and(
eq(circuitSections.id, snapshot.sectionId),
eq(
circuitSections.circuitListId,
snapshot.circuitListId
)
)
)
.get();
if (!section) {
throw new Error("Section does not belong to circuit list.");
}
}
private assertTargetCircuitAvailable(
database: AppDatabase,
snapshot: CircuitSnapshot
) {
const existingCircuit = database
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, snapshot.id))
.get();
if (existingCircuit) {
throw new Error("Move target circuit id already exists.");
}
const duplicateIdentifier = database
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, snapshot.circuitListId),
eq(
circuits.equipmentIdentifier,
snapshot.equipmentIdentifier
)
)
)
.get();
if (duplicateIdentifier) {
throw new Error(
"Duplicate equipmentIdentifier in circuit list."
);
}
}
private insertTargetCircuit(
database: AppDatabase,
snapshot: CircuitSnapshot
) {
database
.insert(circuits)
.values({
id: snapshot.id,
circuitListId: snapshot.circuitListId,
sectionId: snapshot.sectionId,
equipmentIdentifier: snapshot.equipmentIdentifier,
displayName: snapshot.displayName,
sortOrder: snapshot.sortOrder,
protectionType: snapshot.protectionType,
protectionRatedCurrent: snapshot.protectionRatedCurrent,
protectionCharacteristic: snapshot.protectionCharacteristic,
cableType: snapshot.cableType,
cableCrossSection: snapshot.cableCrossSection,
cableLength: snapshot.cableLength,
rcdAssignment: snapshot.rcdAssignment,
terminalDesignation: snapshot.terminalDesignation,
voltage: snapshot.voltage,
controlRequirement: snapshot.controlRequirement,
status: snapshot.status,
isReserve: 1,
remark: snapshot.remark,
})
.run();
}
private assertTargetCircuitUnchanged(
database: AppDatabase,
snapshot: CircuitSnapshot,
expectedRowIds: string[]
) {
const current = database
.select()
.from(circuits)
.where(eq(circuits.id, snapshot.id))
.get();
if (
!current ||
current.circuitListId !== snapshot.circuitListId ||
current.sectionId !== snapshot.sectionId ||
current.equipmentIdentifier !==
snapshot.equipmentIdentifier ||
current.displayName !== snapshot.displayName ||
current.sortOrder !== snapshot.sortOrder ||
current.protectionType !== snapshot.protectionType ||
current.protectionRatedCurrent !==
snapshot.protectionRatedCurrent ||
current.protectionCharacteristic !==
snapshot.protectionCharacteristic ||
current.cableType !== snapshot.cableType ||
current.cableCrossSection !== snapshot.cableCrossSection ||
current.cableLength !== snapshot.cableLength ||
current.rcdAssignment !== snapshot.rcdAssignment ||
current.terminalDesignation !==
snapshot.terminalDesignation ||
current.voltage !== snapshot.voltage ||
current.controlRequirement !==
snapshot.controlRequirement ||
current.status !== snapshot.status ||
current.remark !== snapshot.remark ||
Boolean(current.isReserve)
) {
throw new Error(
"Created target circuit changed before command execution."
);
}
const currentRowIds = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, snapshot.id))
.all()
.map((row) => row.id);
const expectedRowIdSet = new Set(expectedRowIds);
if (
currentRowIds.length !== expectedRowIds.length ||
currentRowIds.some((id) => !expectedRowIdSet.has(id))
) {
throw new Error(
"Created target circuit rows changed before command execution."
);
}
}
private reverseMoves(
moves: CircuitDeviceRowMoveAssignment[],
rowsById: Map<string, PersistedMoveRow>
) {
return moves.map((move) => {
const current = rowsById.get(move.rowId)!;
return {
rowId: move.rowId,
expectedCircuitId: move.targetCircuitId,
expectedSortOrder: move.targetSortOrder,
targetCircuitId: current.circuitId,
targetSortOrder: current.sortOrder,
};
});
}
private applyMoves(
database: AppDatabase,
moves: CircuitDeviceRowMoveAssignment[]
) {
for (const move of moves) {
const result = database
.update(circuitDeviceRows)
.set({
circuitId: move.targetCircuitId,
sortOrder: move.targetSortOrder,
})
.where(
and(
eq(circuitDeviceRows.id, move.rowId),
eq(
circuitDeviceRows.circuitId,
move.expectedCircuitId
),
eq(
circuitDeviceRows.sortOrder,
move.expectedSortOrder
)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"Circuit device row changed during move execution."
);
}
}
}
private updateReserveStates(
database: AppDatabase,
circuitIds: string[]
) {
for (const circuitId of new Set(circuitIds)) {
const remainingRow = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuitId))
.limit(1)
.get();
const updated = database
.update(circuits)
.set({ isReserve: remainingRow ? 0 : 1 })
.where(eq(circuits.id, circuitId))
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during device-row move execution."
);
}
}
}
}
@@ -0,0 +1,189 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitDeviceRowUpdateProjectCommand,
createCircuitDeviceRowUpdateProjectCommand,
type CircuitDeviceRowUpdateField,
type CircuitDeviceRowUpdatePatch,
type CircuitDeviceRowUpdateValues,
} from "../../domain/models/circuit-device-row-project-command.model.js";
import type {
CircuitDeviceRowProjectCommandStore,
ExecuteCircuitDeviceRowUpdateCommandInput,
} from "../../domain/ports/circuit-device-row-project-command.store.js";
import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js";
import { rooms } from "../schema/rooms.js";
import {
toCircuitDeviceRowPatchValues,
type CircuitDeviceRowPatchInput,
} from "./circuit-device-row.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
export class CircuitDeviceRowProjectCommandRepository
implements CircuitDeviceRowProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) {
assertCircuitDeviceRowUpdateProjectCommand(input.command);
return this.database.transaction((tx) => {
const current = tx
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, input.command.payload.rowId))
.get();
if (!current) {
throw new Error("Invalid device row id.");
}
const circuit = tx
.select({ circuitListId: circuits.circuitListId })
.from(circuits)
.where(eq(circuits.id, current.circuitId))
.get();
const owningList = circuit
? tx
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, circuit.circuitListId),
eq(circuitLists.projectId, input.projectId)
)
)
.get()
: null;
if (!owningList) {
throw new Error("Circuit device row does not belong to project.");
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as CircuitDeviceRowPatchInput;
this.assertLinkedProjectDevice(tx, input.projectId, patch);
this.assertRoom(tx, input.projectId, patch);
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
current,
patch
);
if (
overriddenFields !== undefined &&
patch.overriddenFields === undefined
) {
patch.overriddenFields = overriddenFields;
}
const appliedForward = createCircuitDeviceRowUpdateProjectCommand(
current.id,
patch as CircuitDeviceRowUpdatePatch
);
const inversePatch = Object.fromEntries(
appliedForward.payload.changes.map((change) => [
change.field,
getCircuitDeviceRowFieldValue(current, change.field),
])
) as CircuitDeviceRowUpdatePatch;
const inverse = createCircuitDeviceRowUpdateProjectCommand(
current.id,
inversePatch
);
const update = tx
.update(circuitDeviceRows)
.set(toCircuitDeviceRowPatchValues(patch))
.where(eq(circuitDeviceRows.id, current.id))
.run();
if (update.changes !== 1) {
throw new Error("Circuit device row changed before command execution.");
}
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: appliedForward,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
private assertLinkedProjectDevice(
database: AppDatabase,
projectId: string,
patch: CircuitDeviceRowPatchInput
) {
if (
!Object.prototype.hasOwnProperty.call(patch, "linkedProjectDeviceId") ||
patch.linkedProjectDeviceId === null ||
patch.linkedProjectDeviceId === undefined
) {
return;
}
const device = database
.select({ id: projectDevices.id })
.from(projectDevices)
.where(
and(
eq(projectDevices.id, patch.linkedProjectDeviceId),
eq(projectDevices.projectId, projectId)
)
)
.get();
if (!device) {
throw new Error("Invalid linked project device id.");
}
}
private assertRoom(
database: AppDatabase,
projectId: string,
patch: CircuitDeviceRowPatchInput
) {
if (
!Object.prototype.hasOwnProperty.call(patch, "roomId") ||
patch.roomId === null ||
patch.roomId === undefined
) {
return;
}
const room = database
.select({ id: rooms.id })
.from(rooms)
.where(and(eq(rooms.id, patch.roomId), eq(rooms.projectId, projectId)))
.get();
if (!room) {
throw new Error("Invalid room id.");
}
}
}
function getCircuitDeviceRowFieldValue<
TField extends CircuitDeviceRowUpdateField,
>(
row: CircuitDeviceRow,
field: TField
): CircuitDeviceRowUpdateValues[TField] {
return row[field] as unknown as CircuitDeviceRowUpdateValues[TField];
}
@@ -0,0 +1,199 @@
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 {
assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.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);
assertCircuitDeviceRowReferencesInProject(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.");
}
}
}
@@ -0,0 +1,71 @@
import { and, eq } from "drizzle-orm";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { projectDevices } from "../schema/project-devices.js";
import { rooms } from "../schema/rooms.js";
export function assertCircuitDeviceRowReferencesInProject(
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.");
}
}
}
export 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,
};
}
@@ -20,9 +20,26 @@ export interface CircuitDeviceRowUpdateInput {
overriddenFields?: string;
}
export type CircuitDeviceRowPatchInput = Partial<CircuitDeviceRowUpdateInput> & {
export interface CircuitDeviceRowPatchInput {
linkedProjectDeviceId?: string | null;
sortOrder?: number;
};
name?: string;
displayName?: string;
phaseType?: string | null;
connectionKind?: string | null;
costGroup?: string | null;
category?: string | null;
level?: string | null;
roomId?: string | null;
roomNumberSnapshot?: string | null;
roomNameSnapshot?: string | null;
quantity?: number;
powerPerUnit?: number;
simultaneityFactor?: number;
cosPhi?: number | null;
remark?: string | null;
overriddenFields?: string | null;
}
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
circuitId: string;
@@ -0,0 +1,170 @@
import { and, eq, ne } from "drizzle-orm";
import {
assertCircuitUpdateProjectCommand,
createCircuitUpdateProjectCommand,
type CircuitUpdateField,
type CircuitUpdatePatch,
type CircuitUpdateValues,
} from "../../domain/models/circuit-project-command.model.js";
import type {
CircuitProjectCommandStore,
ExecuteCircuitUpdateCommandInput,
} from "../../domain/ports/circuit-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import {
toCircuitPatchValues,
type CircuitPatchPersistenceInput,
} from "./circuit.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitRow = typeof circuits.$inferSelect;
export class CircuitProjectCommandRepository
implements CircuitProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
executeUpdate(input: ExecuteCircuitUpdateCommandInput) {
assertCircuitUpdateProjectCommand(input.command);
return this.database.transaction((tx) => {
const current = tx
.select()
.from(circuits)
.where(eq(circuits.id, input.command.payload.circuitId))
.get();
if (!current) {
throw new Error("Invalid circuit id.");
}
const owningList = tx
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, current.circuitListId),
eq(circuitLists.projectId, input.projectId)
)
)
.get();
if (!owningList) {
throw new Error("Circuit does not belong to project.");
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as CircuitPatchPersistenceInput;
this.assertSectionInCircuitList(tx, current, patch.sectionId);
this.assertUniqueEquipmentIdentifier(
tx,
current,
patch.equipmentIdentifier
);
const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
getCircuitFieldValue(current, change.field),
])
) as CircuitUpdatePatch;
const inverse = createCircuitUpdateProjectCommand(
current.id,
inversePatch
);
const update = tx
.update(circuits)
.set(toCircuitPatchValues(patch))
.where(eq(circuits.id, current.id))
.run();
if (update.changes !== 1) {
throw new Error("Circuit changed before command execution.");
}
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
private assertSectionInCircuitList(
database: AppDatabase,
circuit: CircuitRow,
sectionId: string | undefined
) {
if (sectionId === undefined || sectionId === circuit.sectionId) {
return;
}
const section = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(
and(
eq(circuitSections.id, sectionId),
eq(circuitSections.circuitListId, circuit.circuitListId)
)
)
.get();
if (!section) {
throw new Error("Section does not belong to circuit list.");
}
}
private assertUniqueEquipmentIdentifier(
database: AppDatabase,
circuit: CircuitRow,
equipmentIdentifier: string | undefined
) {
if (
equipmentIdentifier === undefined ||
equipmentIdentifier === circuit.equipmentIdentifier
) {
return;
}
const duplicate = database
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, circuit.circuitListId),
eq(circuits.equipmentIdentifier, equipmentIdentifier),
ne(circuits.id, circuit.id)
)
)
.get();
if (duplicate) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
}
}
function getCircuitFieldValue<TField extends CircuitUpdateField>(
circuit: CircuitRow,
field: TField
): CircuitUpdateValues[TField] {
if (field === "isReserve") {
return Boolean(circuit.isReserve) as CircuitUpdateValues[TField];
}
return circuit[field] as unknown as CircuitUpdateValues[TField];
}
@@ -0,0 +1,291 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import {
assertCircuitDeleteProjectCommand,
assertCircuitInsertProjectCommand,
circuitDeleteCommandType,
circuitInsertCommandType,
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
type CircuitSnapshot,
type CircuitStructureProjectCommand,
} from "../../domain/models/circuit-structure-project-command.model.js";
import type {
CircuitStructureProjectCommandStore,
ExecuteCircuitStructureCommandInput,
} from "../../domain/ports/circuit-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 { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import {
assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitStructureCommandInput) {
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: ExecuteCircuitStructureCommandInput["source"],
command: CircuitStructureProjectCommand
): CircuitStructureProjectCommand {
if (command.type === circuitInsertCommandType) {
assertCircuitInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
command.payload.circuit
);
}
if (command.type === circuitDeleteCommandType) {
assertCircuitDeleteProjectCommand(command);
return this.delete(
database,
projectId,
command.payload.circuitId,
command.payload.expectedCircuitListId
);
}
throw new Error("Unsupported circuit structure command.");
}
private insert(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
snapshot: CircuitSnapshot
) {
this.assertCircuitLocation(database, projectId, snapshot);
const existingCircuit = database
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, snapshot.id))
.get();
if (existingCircuit) {
throw new Error("Circuit id already exists.");
}
const duplicateIdentifier = database
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, snapshot.circuitListId),
eq(
circuits.equipmentIdentifier,
snapshot.equipmentIdentifier
)
)
)
.get();
if (duplicateIdentifier) {
throw new Error(
"Duplicate equipmentIdentifier in circuit list."
);
}
if (snapshot.deviceRows.length > 0) {
const rowIds = snapshot.deviceRows.map((row) => row.id);
const existingRow = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds))
.limit(1)
.get();
if (existingRow) {
throw new Error("Circuit device-row id already exists.");
}
for (const row of snapshot.deviceRows) {
if (source === "user" && row.legacyConsumerId !== null) {
throw new Error(
"User commands cannot assign legacy consumer ids."
);
}
assertCircuitDeviceRowReferencesInProject(
database,
projectId,
row
);
}
}
database
.insert(circuits)
.values({
id: snapshot.id,
circuitListId: snapshot.circuitListId,
sectionId: snapshot.sectionId,
equipmentIdentifier: snapshot.equipmentIdentifier,
displayName: snapshot.displayName,
sortOrder: snapshot.sortOrder,
protectionType: snapshot.protectionType,
protectionRatedCurrent: snapshot.protectionRatedCurrent,
protectionCharacteristic: snapshot.protectionCharacteristic,
cableType: snapshot.cableType,
cableCrossSection: snapshot.cableCrossSection,
cableLength: snapshot.cableLength,
rcdAssignment: snapshot.rcdAssignment,
terminalDesignation: snapshot.terminalDesignation,
voltage: snapshot.voltage,
controlRequirement: snapshot.controlRequirement,
status: snapshot.status,
isReserve: snapshot.isReserve ? 1 : 0,
remark: snapshot.remark,
})
.run();
if (snapshot.deviceRows.length > 0) {
database.insert(circuitDeviceRows).values(snapshot.deviceRows).run();
}
return createCircuitDeleteProjectCommand(
snapshot.id,
snapshot.circuitListId
);
}
private delete(
database: AppDatabase,
projectId: string,
circuitId: string,
expectedCircuitListId: string
) {
const circuit = database
.select()
.from(circuits)
.where(eq(circuits.id, circuitId))
.get();
if (!circuit || circuit.circuitListId !== expectedCircuitListId) {
throw new Error("Circuit changed before command execution.");
}
const owningList = database
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, circuit.circuitListId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!owningList) {
throw new Error("Circuit does not belong to project.");
}
const rows = database
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuit.id))
.orderBy(
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
const inverse = createCircuitInsertProjectCommand({
id: circuit.id,
circuitListId: circuit.circuitListId,
sectionId: circuit.sectionId,
equipmentIdentifier: circuit.equipmentIdentifier,
displayName: circuit.displayName,
sortOrder: circuit.sortOrder,
protectionType: circuit.protectionType,
protectionRatedCurrent: circuit.protectionRatedCurrent,
protectionCharacteristic: circuit.protectionCharacteristic,
cableType: circuit.cableType,
cableCrossSection: circuit.cableCrossSection,
cableLength: circuit.cableLength,
rcdAssignment: circuit.rcdAssignment,
terminalDesignation: circuit.terminalDesignation,
voltage: circuit.voltage,
controlRequirement: circuit.controlRequirement,
status: circuit.status,
isReserve: Boolean(circuit.isReserve),
remark: circuit.remark,
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
});
const result = database
.delete(circuits)
.where(
and(
eq(circuits.id, circuitId),
eq(circuits.circuitListId, expectedCircuitListId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("Circuit could not be deleted.");
}
return inverse;
}
private assertCircuitLocation(
database: AppDatabase,
projectId: string,
snapshot: CircuitSnapshot
) {
const list = database
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, snapshot.circuitListId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!list) {
throw new Error("Circuit list does not belong to project.");
}
const section = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(
and(
eq(circuitSections.id, snapshot.sectionId),
eq(
circuitSections.circuitListId,
snapshot.circuitListId
)
)
)
.get();
if (!section) {
throw new Error("Section does not belong to circuit list.");
}
}
}
@@ -19,6 +19,26 @@ export interface CircuitCreatePersistenceInput {
isReserve?: boolean;
}
export interface CircuitPatchPersistenceInput {
sectionId?: string;
equipmentIdentifier?: string;
displayName?: string | null;
sortOrder?: number;
protectionType?: string | null;
protectionRatedCurrent?: number | null;
protectionCharacteristic?: string | null;
cableType?: string | null;
cableCrossSection?: string | null;
cableLength?: number | null;
voltage?: number | null;
controlRequirement?: string | null;
remark?: string | null;
rcdAssignment?: string | null;
terminalDesignation?: string | null;
status?: string | null;
isReserve?: boolean;
}
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
return {
id,
@@ -42,3 +62,41 @@ export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenc
remark: input.remark ?? null,
};
}
export function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
const values: Partial<ReturnType<typeof toCircuitCreateValues>> = {};
const has = (field: keyof CircuitPatchPersistenceInput) =>
Object.prototype.hasOwnProperty.call(input, field);
if (input.sectionId !== undefined) values.sectionId = input.sectionId;
if (input.equipmentIdentifier !== undefined) {
values.equipmentIdentifier = input.equipmentIdentifier;
}
if (has("displayName")) values.displayName = input.displayName ?? null;
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
if (has("protectionType")) values.protectionType = input.protectionType ?? null;
if (has("protectionRatedCurrent")) {
values.protectionRatedCurrent = input.protectionRatedCurrent ?? null;
}
if (has("protectionCharacteristic")) {
values.protectionCharacteristic = input.protectionCharacteristic ?? null;
}
if (has("cableType")) values.cableType = input.cableType ?? null;
if (has("cableCrossSection")) {
values.cableCrossSection = input.cableCrossSection ?? null;
}
if (has("cableLength")) values.cableLength = input.cableLength ?? null;
if (has("rcdAssignment")) values.rcdAssignment = input.rcdAssignment ?? null;
if (has("terminalDesignation")) {
values.terminalDesignation = input.terminalDesignation ?? null;
}
if (has("voltage")) values.voltage = input.voltage ?? null;
if (has("controlRequirement")) {
values.controlRequirement = input.controlRequirement ?? null;
}
if (has("status")) values.status = input.status ?? null;
if (input.isReserve !== undefined) values.isReserve = input.isReserve ? 1 : 0;
if (has("remark")) values.remark = input.remark ?? null;
return values;
}
+10 -58
View File
@@ -4,67 +4,19 @@ import { db } from "../client.js";
import { circuits } from "../schema/circuits.js";
import {
toCircuitCreateValues,
toCircuitPatchValues,
type CircuitCreatePersistenceInput,
type CircuitPatchPersistenceInput,
} from "./circuit.persistence.js";
export type { CircuitCreatePersistenceInput } from "./circuit.persistence.js";
export { toCircuitCreateValues } from "./circuit.persistence.js";
export interface CircuitPatchPersistenceInput {
sectionId?: string;
equipmentIdentifier?: string;
displayName?: string;
sortOrder?: number;
protectionType?: string;
protectionRatedCurrent?: number;
protectionCharacteristic?: string;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
voltage?: number;
controlRequirement?: string;
remark?: string;
rcdAssignment?: string;
terminalDesignation?: string;
status?: string;
isReserve?: boolean;
}
function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
const values: Partial<typeof circuits.$inferInsert> = {};
const has = (field: keyof CircuitPatchPersistenceInput) =>
Object.prototype.hasOwnProperty.call(input, field);
if (input.sectionId !== undefined) values.sectionId = input.sectionId;
if (input.equipmentIdentifier !== undefined) {
values.equipmentIdentifier = input.equipmentIdentifier;
}
if (has("displayName")) values.displayName = input.displayName ?? null;
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
if (has("protectionType")) values.protectionType = input.protectionType ?? null;
if (has("protectionRatedCurrent")) {
values.protectionRatedCurrent = input.protectionRatedCurrent ?? null;
}
if (has("protectionCharacteristic")) {
values.protectionCharacteristic = input.protectionCharacteristic ?? null;
}
if (has("cableType")) values.cableType = input.cableType ?? null;
if (has("cableCrossSection")) values.cableCrossSection = input.cableCrossSection ?? null;
if (has("cableLength")) values.cableLength = input.cableLength ?? null;
if (has("rcdAssignment")) values.rcdAssignment = input.rcdAssignment ?? null;
if (has("terminalDesignation")) {
values.terminalDesignation = input.terminalDesignation ?? null;
}
if (has("voltage")) values.voltage = input.voltage ?? null;
if (has("controlRequirement")) {
values.controlRequirement = input.controlRequirement ?? null;
}
if (has("status")) values.status = input.status ?? null;
if (input.isReserve !== undefined) values.isReserve = input.isReserve ? 1 : 0;
if (has("remark")) values.remark = input.remark ?? null;
return values;
}
export type {
CircuitCreatePersistenceInput,
CircuitPatchPersistenceInput,
} from "./circuit.persistence.js";
export {
toCircuitCreateValues,
toCircuitPatchValues,
} from "./circuit.persistence.js";
export class CircuitRepository {
async findById(circuitId: string) {
@@ -0,0 +1,121 @@
import { and, desc, eq, max } from "drizzle-orm";
import type { ProjectRevisionSource } from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
export interface ApplyProjectHistoryTransitionInput {
projectId: string;
source: ProjectRevisionSource;
recordedChangeSetId: string;
targetChangeSetId?: string;
}
export function applyProjectHistoryTransition(
database: AppDatabase,
input: ApplyProjectHistoryTransitionInput
) {
if (input.source === "user") {
database
.delete(projectHistoryStackEntries)
.where(
and(
eq(projectHistoryStackEntries.projectId, input.projectId),
eq(projectHistoryStackEntries.stack, "redo")
)
)
.run();
push(
database,
input.projectId,
input.recordedChangeSetId,
"undo"
);
return;
}
if (input.source === "undo" || input.source === "redo") {
if (!input.targetChangeSetId) {
throw new Error(
`${input.source} requires a target change set.`
);
}
const sourceStack = input.source;
const targetStack = input.source === "undo" ? "redo" : "undo";
const top = database
.select({
changeSetId: projectHistoryStackEntries.changeSetId,
})
.from(projectHistoryStackEntries)
.where(
and(
eq(projectHistoryStackEntries.projectId, input.projectId),
eq(projectHistoryStackEntries.stack, sourceStack)
)
)
.orderBy(desc(projectHistoryStackEntries.position))
.limit(1)
.get();
if (!top || top.changeSetId !== input.targetChangeSetId) {
throw new Error(
`${input.source} target is not the top ${sourceStack} command.`
);
}
const nextPosition = getNextPosition(
database,
input.projectId,
targetStack
);
const moved = database
.update(projectHistoryStackEntries)
.set({ stack: targetStack, position: nextPosition })
.where(
and(
eq(projectHistoryStackEntries.projectId, input.projectId),
eq(
projectHistoryStackEntries.changeSetId,
input.targetChangeSetId
)
)
)
.run();
if (moved.changes !== 1) {
throw new Error("Project history stack changed during transition.");
}
}
}
function push(
database: AppDatabase,
projectId: string,
changeSetId: string,
stack: "undo" | "redo"
) {
database
.insert(projectHistoryStackEntries)
.values({
projectId,
changeSetId,
stack,
position: getNextPosition(database, projectId, stack),
})
.run();
}
function getNextPosition(
database: AppDatabase,
projectId: string,
stack: "undo" | "redo"
) {
const current = database
.select({ position: max(projectHistoryStackEntries.position) })
.from(projectHistoryStackEntries)
.where(
and(
eq(projectHistoryStackEntries.projectId, projectId),
eq(projectHistoryStackEntries.stack, stack)
)
)
.get();
return (current?.position ?? 0) + 1;
}
@@ -0,0 +1,95 @@
import { and, asc, desc, eq } from "drizzle-orm";
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
import type {
ProjectHistoryCommand,
ProjectHistoryDirection,
ProjectHistoryState,
ProjectHistoryStore,
} from "../../domain/ports/project-history.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
import { projectRevisions } from "../schema/project-revisions.js";
import { projects } from "../schema/projects.js";
export class ProjectHistoryRepository implements ProjectHistoryStore {
constructor(private readonly database: AppDatabase) {}
getState(projectId: string): ProjectHistoryState | null {
const project = this.database
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
return null;
}
const entries = this.database
.select()
.from(projectHistoryStackEntries)
.where(eq(projectHistoryStackEntries.projectId, projectId))
.orderBy(
asc(projectHistoryStackEntries.stack),
asc(projectHistoryStackEntries.position)
)
.all();
const undoEntries = entries.filter((entry) => entry.stack === "undo");
const redoEntries = entries.filter((entry) => entry.stack === "redo");
return {
projectId,
currentRevision: project.currentRevision,
undoDepth: undoEntries.length,
redoDepth: redoEntries.length,
undoChangeSetId: undoEntries.at(-1)?.changeSetId ?? null,
redoChangeSetId: redoEntries.at(-1)?.changeSetId ?? null,
};
}
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
): ProjectHistoryCommand | null {
const entry = this.database
.select({
changeSetId: projectHistoryStackEntries.changeSetId,
forwardPayloadJson: projectChangeSets.forwardPayloadJson,
inversePayloadJson: projectChangeSets.inversePayloadJson,
})
.from(projectHistoryStackEntries)
.innerJoin(
projectChangeSets,
eq(
projectChangeSets.id,
projectHistoryStackEntries.changeSetId
)
)
.innerJoin(
projectRevisions,
eq(projectRevisions.id, projectChangeSets.projectRevisionId)
)
.where(
and(
eq(projectHistoryStackEntries.projectId, projectId),
eq(projectHistoryStackEntries.stack, direction),
eq(projectRevisions.projectId, projectId)
)
)
.orderBy(desc(projectHistoryStackEntries.position))
.limit(1)
.get();
if (!entry) {
return null;
}
return {
changeSetId: entry.changeSetId,
command: deserializeProjectCommand(
direction === "undo"
? entry.inversePayloadJson
: entry.forwardPayloadJson
),
};
}
}
@@ -0,0 +1,94 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { serializeProjectCommand } from "../../domain/models/project-command.model.js";
import type {
AppendProjectRevisionInput,
AppendedProjectRevision,
} from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projectRevisions } from "../schema/project-revisions.js";
import { projects } from "../schema/projects.js";
export function appendProjectRevision(
database: AppDatabase,
input: AppendProjectRevisionInput
): AppendedProjectRevision {
validateInput(input);
const forwardPayloadJson = serializeProjectCommand(input.forward);
const inversePayloadJson = serializeProjectCommand(input.inverse);
const revisionId = crypto.randomUUID();
const changeSetId = crypto.randomUUID();
const revisionNumber = input.expectedRevision + 1;
const createdAtIso = new Date().toISOString();
const revisionUpdate = database
.update(projects)
.set({ currentRevision: revisionNumber })
.where(
and(
eq(projects.id, input.projectId),
eq(projects.currentRevision, input.expectedRevision)
)
)
.run();
if (revisionUpdate.changes !== 1) {
const current = database
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
throw new ProjectRevisionConflictError(
input.projectId,
input.expectedRevision,
current?.currentRevision ?? null
);
}
database
.insert(projectRevisions)
.values({
id: revisionId,
projectId: input.projectId,
revisionNumber,
createdAtIso,
actorId: input.actorId,
source: input.source,
description: input.description,
})
.run();
database
.insert(projectChangeSets)
.values({
id: changeSetId,
projectRevisionId: revisionId,
commandType: input.forward.type,
payloadSchemaVersion: input.forward.schemaVersion,
forwardPayloadJson,
inversePayloadJson,
})
.run();
return {
revisionId,
changeSetId,
projectId: input.projectId,
revisionNumber,
createdAtIso,
};
}
function validateInput(input: AppendProjectRevisionInput) {
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) {
throw new Error("Expected project revision must be a non-negative integer.");
}
if (input.forward.schemaVersion !== input.inverse.schemaVersion) {
throw new Error(
"Forward and inverse project commands require the same schema version."
);
}
}
@@ -0,0 +1,31 @@
import { eq } from "drizzle-orm";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import type {
AppendProjectRevisionInput,
AppendedProjectRevision,
ProjectRevisionStore,
} from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { projects } from "../schema/projects.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export { ProjectRevisionConflictError };
export class ProjectRevisionRepository implements ProjectRevisionStore {
constructor(private readonly database: AppDatabase) {}
getCurrentRevision(projectId: string) {
const project = this.database
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, projectId))
.get();
return project?.currentRevision ?? null;
}
append(input: AppendProjectRevisionInput): AppendedProjectRevision {
return this.database.transaction((tx) =>
appendProjectRevision(tx, input)
);
}
}
@@ -19,6 +19,7 @@ export class ProjectRepository {
name: input.name,
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
currentRevision: 0,
};
await db.insert(projects).values(project);
return project;
+19
View File
@@ -0,0 +1,19 @@
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { projectRevisions } from "./project-revisions.js";
export const projectChangeSets = sqliteTable(
"project_change_sets",
{
id: text("id").primaryKey(),
projectRevisionId: text("project_revision_id")
.notNull()
.references(() => projectRevisions.id, { onDelete: "cascade" }),
commandType: text("command_type").notNull(),
payloadSchemaVersion: integer("payload_schema_version").notNull(),
forwardPayloadJson: text("forward_payload_json").notNull(),
inversePayloadJson: text("inverse_payload_json").notNull(),
},
(table) => [
unique("project_change_sets_revision_unique").on(table.projectRevisionId),
]
);
@@ -0,0 +1,35 @@
import { sql } from "drizzle-orm";
import {
check,
integer,
sqliteTable,
text,
unique,
} from "drizzle-orm/sqlite-core";
import { projectChangeSets } from "./project-change-sets.js";
import { projects } from "./projects.js";
export const projectHistoryStackEntries = sqliteTable(
"project_history_stack_entries",
{
changeSetId: text("change_set_id")
.primaryKey()
.references(() => projectChangeSets.id, { onDelete: "cascade" }),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
stack: text("stack").notNull(),
position: integer("position").notNull(),
},
(table) => [
check(
"project_history_stack_entries_stack_check",
sql`${table.stack} in ('undo', 'redo')`
),
unique("project_history_stack_entries_position_unique").on(
table.projectId,
table.stack,
table.position
),
]
);
+23
View File
@@ -0,0 +1,23 @@
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { projects } from "./projects.js";
export const projectRevisions = sqliteTable(
"project_revisions",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
revisionNumber: integer("revision_number").notNull(),
createdAtIso: text("created_at_iso").notNull(),
actorId: text("actor_id"),
source: text("source").notNull(),
description: text("description"),
},
(table) => [
unique("project_revisions_project_number_unique").on(
table.projectId,
table.revisionNumber
),
]
);
+1
View File
@@ -5,4 +5,5 @@ export const projects = sqliteTable("projects", {
name: text("name").notNull(),
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
currentRevision: integer("current_revision").notNull().default(0),
});
@@ -0,0 +1,13 @@
import type { ProjectHistoryDirection } from "../ports/project-history.store.js";
export class ProjectHistoryOperationUnavailableError extends Error {
constructor(
readonly projectId: string,
readonly direction: ProjectHistoryDirection
) {
super(
`Project ${projectId} has no ${direction} operation available.`
);
this.name = "ProjectHistoryOperationUnavailableError";
}
}
@@ -0,0 +1,14 @@
export class ProjectRevisionConflictError extends Error {
constructor(
readonly projectId: string,
readonly expectedRevision: number,
readonly actualRevision: number | null
) {
super(
actualRevision === null
? `Project ${projectId} does not exist.`
: `Project ${projectId} is at revision ${actualRevision}, expected ${expectedRevision}.`
);
this.name = "ProjectRevisionConflictError";
}
}
@@ -0,0 +1,230 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
assertCircuitInsertProjectCommand,
circuitInsertCommandType,
circuitStructureCommandSchemaVersion,
type CircuitSnapshot,
} from "./circuit-structure-project-command.model.js";
export const circuitDeviceRowMoveCommandType =
"circuit-device-row.move" as const;
export const circuitDeviceRowMoveWithNewCircuitCommandType =
"circuit-device-row.move-with-new-circuit" as const;
export const circuitDeviceRowMoveCommandSchemaVersion = 1 as const;
export interface CircuitDeviceRowMoveAssignment {
rowId: string;
expectedCircuitId: string;
expectedSortOrder: number;
targetCircuitId: string;
targetSortOrder: number;
}
export interface CircuitDeviceRowMoveCommandPayload {
moves: CircuitDeviceRowMoveAssignment[];
}
export interface CircuitDeviceRowMoveProjectCommand
extends SerializedProjectCommand<CircuitDeviceRowMoveCommandPayload> {
schemaVersion: typeof circuitDeviceRowMoveCommandSchemaVersion;
type: typeof circuitDeviceRowMoveCommandType;
}
export type CircuitDeviceRowMoveTargetCircuitAction =
| "create"
| "delete";
export interface CircuitDeviceRowMoveWithNewCircuitCommandPayload {
targetCircuitAction: CircuitDeviceRowMoveTargetCircuitAction;
targetCircuit: CircuitSnapshot;
moves: CircuitDeviceRowMoveAssignment[];
}
export interface CircuitDeviceRowMoveWithNewCircuitProjectCommand
extends SerializedProjectCommand<CircuitDeviceRowMoveWithNewCircuitCommandPayload> {
schemaVersion: typeof circuitDeviceRowMoveCommandSchemaVersion;
type: typeof circuitDeviceRowMoveWithNewCircuitCommandType;
}
export type CircuitDeviceRowMoveHistoryProjectCommand =
| CircuitDeviceRowMoveProjectCommand
| CircuitDeviceRowMoveWithNewCircuitProjectCommand;
export function createCircuitDeviceRowMoveProjectCommand(
moves: CircuitDeviceRowMoveAssignment[]
): CircuitDeviceRowMoveProjectCommand {
const command: CircuitDeviceRowMoveProjectCommand = {
schemaVersion: circuitDeviceRowMoveCommandSchemaVersion,
type: circuitDeviceRowMoveCommandType,
payload: { moves },
};
assertCircuitDeviceRowMoveProjectCommand(command);
return command;
}
export function createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
targetCircuitAction: CircuitDeviceRowMoveTargetCircuitAction,
targetCircuit: CircuitSnapshot,
moves: CircuitDeviceRowMoveAssignment[]
): CircuitDeviceRowMoveWithNewCircuitProjectCommand {
const command: CircuitDeviceRowMoveWithNewCircuitProjectCommand = {
schemaVersion: circuitDeviceRowMoveCommandSchemaVersion,
type: circuitDeviceRowMoveWithNewCircuitCommandType,
payload: {
targetCircuitAction,
targetCircuit,
moves,
},
};
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(command);
return command;
}
export function assertCircuitDeviceRowMoveProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitDeviceRowMoveProjectCommand {
if (
command.schemaVersion !== circuitDeviceRowMoveCommandSchemaVersion ||
command.type !== circuitDeviceRowMoveCommandType
) {
throw new Error("Unsupported circuit device-row move command.");
}
if (!isPlainObject(command.payload)) {
throw new Error(
"Circuit device-row move payload must be an object."
);
}
const moves = command.payload.moves;
if (!Array.isArray(moves) || moves.length === 0) {
throw new Error(
"Circuit device-row move command requires at least one move."
);
}
const rowIds = new Set<string>();
for (const move of moves) {
if (!isPlainObject(move)) {
throw new Error(
"Circuit device-row move command contains an invalid move."
);
}
assertNonEmptyString(move.rowId, "rowId");
assertNonEmptyString(
move.expectedCircuitId,
"expectedCircuitId"
);
assertFiniteNumber(
move.expectedSortOrder,
"expectedSortOrder"
);
assertNonEmptyString(move.targetCircuitId, "targetCircuitId");
assertFiniteNumber(move.targetSortOrder, "targetSortOrder");
if (rowIds.has(move.rowId)) {
throw new Error(
"Circuit device-row move command contains duplicate row ids."
);
}
if (
move.expectedCircuitId === move.targetCircuitId &&
move.expectedSortOrder === move.targetSortOrder
) {
throw new Error(
"Circuit device-row move command contains a no-op move."
);
}
rowIds.add(move.rowId);
}
}
export function assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitDeviceRowMoveWithNewCircuitProjectCommand {
if (
command.schemaVersion !== circuitDeviceRowMoveCommandSchemaVersion ||
command.type !== circuitDeviceRowMoveWithNewCircuitCommandType
) {
throw new Error(
"Unsupported circuit device-row move-with-new-circuit command."
);
}
if (!isPlainObject(command.payload)) {
throw new Error(
"Circuit device-row move-with-new-circuit payload must be an object."
);
}
const { targetCircuitAction, targetCircuit, moves } =
command.payload;
if (
targetCircuitAction !== "create" &&
targetCircuitAction !== "delete"
) {
throw new Error(
"Target circuit action must be create or delete."
);
}
assertCircuitInsertProjectCommand({
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitInsertCommandType,
payload: { circuit: targetCircuit },
});
const validatedTargetCircuit = targetCircuit as CircuitSnapshot;
if (
validatedTargetCircuit.isReserve !== true ||
validatedTargetCircuit.deviceRows.length !== 0
) {
throw new Error(
"Move target circuit snapshot must describe an empty reserve circuit."
);
}
assertCircuitDeviceRowMoveProjectCommand({
schemaVersion: circuitDeviceRowMoveCommandSchemaVersion,
type: circuitDeviceRowMoveCommandType,
payload: { moves },
});
const validatedMoves = moves as CircuitDeviceRowMoveAssignment[];
for (const move of validatedMoves) {
if (
targetCircuitAction === "create" &&
(move.expectedCircuitId === validatedTargetCircuit.id ||
move.targetCircuitId !== validatedTargetCircuit.id)
) {
throw new Error(
"Create-target moves must move rows into the new circuit."
);
}
if (
targetCircuitAction === "delete" &&
(move.expectedCircuitId !== validatedTargetCircuit.id ||
move.targetCircuitId === validatedTargetCircuit.id)
) {
throw new Error(
"Delete-target moves must move rows out of the created circuit."
);
}
}
}
function assertNonEmptyString(
value: unknown,
field: string
): asserts value is string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
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 isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,177 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitDeviceRowUpdateCommandType =
"circuit-device-row.update" as const;
export const circuitDeviceRowUpdateCommandSchemaVersion = 1 as const;
export interface CircuitDeviceRowUpdateValues {
linkedProjectDeviceId: string | null;
sortOrder: number;
name: string;
displayName: string;
phaseType: string | null;
connectionKind: string | null;
costGroup: string | null;
category: string | null;
level: string | null;
roomId: string | null;
roomNumberSnapshot: string | null;
roomNameSnapshot: string | null;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi: number | null;
remark: string | null;
overriddenFields: string | null;
}
export type CircuitDeviceRowUpdateField = keyof CircuitDeviceRowUpdateValues;
export type CircuitDeviceRowUpdatePatch =
Partial<CircuitDeviceRowUpdateValues>;
export interface CircuitDeviceRowUpdateFieldChange<
TField extends CircuitDeviceRowUpdateField = CircuitDeviceRowUpdateField,
> {
field: TField;
value: CircuitDeviceRowUpdateValues[TField];
}
export interface CircuitDeviceRowUpdateCommandPayload {
rowId: string;
changes: CircuitDeviceRowUpdateFieldChange[];
}
export interface CircuitDeviceRowUpdateProjectCommand
extends SerializedProjectCommand<CircuitDeviceRowUpdateCommandPayload> {
schemaVersion: typeof circuitDeviceRowUpdateCommandSchemaVersion;
type: typeof circuitDeviceRowUpdateCommandType;
}
export function createCircuitDeviceRowUpdateProjectCommand(
rowId: string,
patch: CircuitDeviceRowUpdatePatch
): CircuitDeviceRowUpdateProjectCommand {
const changes = Object.entries(patch).map(([field, value]) => ({
field: field as CircuitDeviceRowUpdateField,
value,
})) as CircuitDeviceRowUpdateFieldChange[];
const command: CircuitDeviceRowUpdateProjectCommand = {
schemaVersion: circuitDeviceRowUpdateCommandSchemaVersion,
type: circuitDeviceRowUpdateCommandType,
payload: { rowId, changes },
};
assertCircuitDeviceRowUpdateProjectCommand(command);
return command;
}
export function assertCircuitDeviceRowUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitDeviceRowUpdateProjectCommand {
if (
command.schemaVersion !== circuitDeviceRowUpdateCommandSchemaVersion ||
command.type !== circuitDeviceRowUpdateCommandType
) {
throw new Error("Unsupported circuit device-row update command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Circuit device-row update payload must be an object.");
}
const rowId = command.payload.rowId;
const changes = command.payload.changes;
if (typeof rowId !== "string" || !rowId.trim()) {
throw new Error("Circuit device-row update command requires a row id.");
}
if (!Array.isArray(changes) || changes.length === 0) {
throw new Error(
"Circuit device-row update command requires at least one change."
);
}
const seenFields = new Set<string>();
for (const change of changes) {
if (!isPlainObject(change) || typeof change.field !== "string") {
throw new Error(
"Circuit device-row update command contains an invalid change."
);
}
if (
!isCircuitDeviceRowUpdateField(change.field) ||
seenFields.has(change.field)
) {
throw new Error(
"Circuit device-row update command contains an invalid or duplicate field."
);
}
assertCircuitDeviceRowUpdateFieldValue(change.field, change.value);
seenFields.add(change.field);
}
}
function assertCircuitDeviceRowUpdateFieldValue(
field: CircuitDeviceRowUpdateField,
value: unknown
) {
if (field === "name" || field === "displayName") {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
return;
}
if (field === "sortOrder") {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error("sortOrder must be a finite number.");
}
return;
}
if (
field === "quantity" ||
field === "powerPerUnit" ||
field === "simultaneityFactor"
) {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative finite number.`);
}
return;
}
if (field === "cosPhi") {
if (
value !== null &&
(typeof value !== "number" || !Number.isFinite(value) || value <= 0)
) {
throw new Error("cosPhi must be a positive finite number or null.");
}
return;
}
if (value !== null && typeof value !== "string") {
throw new Error(`${field} must be a string or null.`);
}
}
function isCircuitDeviceRowUpdateField(
value: string
): value is CircuitDeviceRowUpdateField {
return [
"linkedProjectDeviceId",
"sortOrder",
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"level",
"roomId",
"roomNumberSnapshot",
"roomNameSnapshot",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"overriddenFields",
].includes(value);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,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,168 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitUpdateCommandType = "circuit.update" as const;
export const circuitUpdateCommandSchemaVersion = 1 as const;
export interface CircuitUpdateValues {
sectionId: string;
equipmentIdentifier: string;
displayName: string | null;
sortOrder: number;
protectionType: string | null;
protectionRatedCurrent: number | null;
protectionCharacteristic: string | null;
cableType: string | null;
cableCrossSection: string | null;
cableLength: number | null;
rcdAssignment: string | null;
terminalDesignation: string | null;
voltage: number | null;
controlRequirement: string | null;
status: string | null;
isReserve: boolean;
remark: string | null;
}
export type CircuitUpdateField = keyof CircuitUpdateValues;
export type CircuitUpdatePatch = Partial<CircuitUpdateValues>;
export interface CircuitUpdateFieldChange<
TField extends CircuitUpdateField = CircuitUpdateField,
> {
field: TField;
value: CircuitUpdateValues[TField];
}
export interface CircuitUpdateCommandPayload {
circuitId: string;
changes: CircuitUpdateFieldChange[];
}
export interface CircuitUpdateProjectCommand
extends SerializedProjectCommand<CircuitUpdateCommandPayload> {
schemaVersion: typeof circuitUpdateCommandSchemaVersion;
type: typeof circuitUpdateCommandType;
}
export function createCircuitUpdateProjectCommand(
circuitId: string,
patch: CircuitUpdatePatch
): CircuitUpdateProjectCommand {
const changes = Object.entries(patch).map(([field, value]) => ({
field: field as CircuitUpdateField,
value,
})) as CircuitUpdateFieldChange[];
const command: CircuitUpdateProjectCommand = {
schemaVersion: circuitUpdateCommandSchemaVersion,
type: circuitUpdateCommandType,
payload: { circuitId, changes },
};
assertCircuitUpdateProjectCommand(command);
return command;
}
export function assertCircuitUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitUpdateProjectCommand {
if (
command.schemaVersion !== circuitUpdateCommandSchemaVersion ||
command.type !== circuitUpdateCommandType
) {
throw new Error("Unsupported circuit update command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Circuit update payload must be an object.");
}
const circuitId = command.payload.circuitId;
const changes = command.payload.changes;
if (typeof circuitId !== "string" || !circuitId.trim()) {
throw new Error("Circuit update command requires a circuit id.");
}
if (!Array.isArray(changes) || changes.length === 0) {
throw new Error("Circuit update command requires at least one change.");
}
const seenFields = new Set<string>();
for (const change of changes) {
if (!isPlainObject(change) || typeof change.field !== "string") {
throw new Error("Circuit update command contains an invalid change.");
}
if (!isCircuitUpdateField(change.field) || seenFields.has(change.field)) {
throw new Error("Circuit update command contains an invalid or duplicate field.");
}
assertCircuitUpdateFieldValue(change.field, change.value);
seenFields.add(change.field);
}
}
function assertCircuitUpdateFieldValue(
field: CircuitUpdateField,
value: unknown
) {
if (field === "sectionId" || field === "equipmentIdentifier") {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
return;
}
if (field === "sortOrder") {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error("sortOrder must be a finite number.");
}
return;
}
if (field === "isReserve") {
if (typeof value !== "boolean") {
throw new Error("isReserve must be a boolean.");
}
return;
}
if (
field === "protectionRatedCurrent" ||
field === "cableLength" ||
field === "voltage"
) {
if (value === null) {
return;
}
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${field} must be a finite number or null.`);
}
if (
(field === "voltage" && value <= 0) ||
(field !== "voltage" && value < 0)
) {
throw new Error(`${field} is outside its allowed range.`);
}
return;
}
if (value !== null && typeof value !== "string") {
throw new Error(`${field} must be a string or null.`);
}
}
function isCircuitUpdateField(value: string): value is CircuitUpdateField {
return [
"sectionId",
"equipmentIdentifier",
"displayName",
"sortOrder",
"protectionType",
"protectionRatedCurrent",
"protectionCharacteristic",
"cableType",
"cableCrossSection",
"cableLength",
"rcdAssignment",
"terminalDesignation",
"voltage",
"controlRequirement",
"status",
"isReserve",
"remark",
].includes(value);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,220 @@
import {
assertCircuitDeviceRowInsertProjectCommand,
circuitDeviceRowInsertCommandType,
circuitDeviceRowStructureCommandSchemaVersion,
type CircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitInsertCommandType = "circuit.insert" as const;
export const circuitDeleteCommandType = "circuit.delete" as const;
export const circuitStructureCommandSchemaVersion = 1 as const;
export interface CircuitSnapshot {
id: string;
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string | null;
sortOrder: number;
protectionType: string | null;
protectionRatedCurrent: number | null;
protectionCharacteristic: string | null;
cableType: string | null;
cableCrossSection: string | null;
cableLength: number | null;
rcdAssignment: string | null;
terminalDesignation: string | null;
voltage: number | null;
controlRequirement: string | null;
status: string | null;
isReserve: boolean;
remark: string | null;
deviceRows: CircuitDeviceRowSnapshot[];
}
export interface CircuitInsertCommandPayload {
circuit: CircuitSnapshot;
}
export interface CircuitDeleteCommandPayload {
circuitId: string;
expectedCircuitListId: string;
}
export interface CircuitInsertProjectCommand
extends SerializedProjectCommand<CircuitInsertCommandPayload> {
schemaVersion: typeof circuitStructureCommandSchemaVersion;
type: typeof circuitInsertCommandType;
}
export interface CircuitDeleteProjectCommand
extends SerializedProjectCommand<CircuitDeleteCommandPayload> {
schemaVersion: typeof circuitStructureCommandSchemaVersion;
type: typeof circuitDeleteCommandType;
}
export type CircuitStructureProjectCommand =
| CircuitInsertProjectCommand
| CircuitDeleteProjectCommand;
export function createCircuitInsertProjectCommand(
circuit: CircuitSnapshot
): CircuitInsertProjectCommand {
const command: CircuitInsertProjectCommand = {
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitInsertCommandType,
payload: { circuit },
};
assertCircuitInsertProjectCommand(command);
return command;
}
export function createCircuitDeleteProjectCommand(
circuitId: string,
expectedCircuitListId: string
): CircuitDeleteProjectCommand {
const command: CircuitDeleteProjectCommand = {
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitDeleteCommandType,
payload: { circuitId, expectedCircuitListId },
};
assertCircuitDeleteProjectCommand(command);
return command;
}
export function assertCircuitInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitInsertProjectCommand {
if (
command.schemaVersion !== circuitStructureCommandSchemaVersion ||
command.type !== circuitInsertCommandType
) {
throw new Error("Unsupported circuit insert command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Circuit insert payload must be an object.");
}
const circuit = command.payload.circuit;
if (!isPlainObject(circuit)) {
throw new Error("Circuit insert command requires a circuit.");
}
assertNonEmptyString(circuit.id, "circuit.id");
assertNonEmptyString(circuit.circuitListId, "circuit.circuitListId");
assertNonEmptyString(circuit.sectionId, "circuit.sectionId");
assertNonEmptyString(
circuit.equipmentIdentifier,
"circuit.equipmentIdentifier"
);
assertNullableString(circuit.displayName, "circuit.displayName");
assertFiniteNumber(circuit.sortOrder, "circuit.sortOrder");
for (const field of [
"protectionType",
"protectionCharacteristic",
"cableType",
"cableCrossSection",
"rcdAssignment",
"terminalDesignation",
"controlRequirement",
"status",
"remark",
] as const) {
assertNullableString(circuit[field], `circuit.${field}`);
}
assertNullableNonNegativeNumber(
circuit.protectionRatedCurrent,
"circuit.protectionRatedCurrent"
);
assertNullableNonNegativeNumber(
circuit.cableLength,
"circuit.cableLength"
);
if (circuit.voltage !== null) {
assertFiniteNumber(circuit.voltage, "circuit.voltage");
if (circuit.voltage <= 0) {
throw new Error("circuit.voltage must be positive or null.");
}
}
if (typeof circuit.isReserve !== "boolean") {
throw new Error("circuit.isReserve must be a boolean.");
}
if (!Array.isArray(circuit.deviceRows)) {
throw new Error("circuit.deviceRows must be an array.");
}
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
throw new Error(
"Circuit reserve state must match whether device rows exist."
);
}
const rowIds = new Set<string>();
for (const row of circuit.deviceRows) {
assertCircuitDeviceRowInsertProjectCommand({
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType,
payload: { row },
});
if (row.circuitId !== circuit.id) {
throw new Error("Circuit device row belongs to a different circuit.");
}
if (rowIds.has(row.id)) {
throw new Error("Circuit insert command contains duplicate row ids.");
}
rowIds.add(row.id);
}
}
export function assertCircuitDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitDeleteProjectCommand {
if (
command.schemaVersion !== circuitStructureCommandSchemaVersion ||
command.type !== circuitDeleteCommandType
) {
throw new Error("Unsupported circuit delete command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Circuit delete payload must be an object.");
}
assertNonEmptyString(command.payload.circuitId, "circuitId");
assertNonEmptyString(
command.payload.expectedCircuitListId,
"expectedCircuitListId"
);
}
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 assertNullableNonNegativeNumber(value: unknown, field: string) {
if (value === null) {
return;
}
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,91 @@
export type ProjectCommandJsonValue =
| null
| boolean
| number
| string
| ProjectCommandJsonValue[]
| { [key: string]: ProjectCommandJsonValue };
export interface SerializedProjectCommand<TPayload = ProjectCommandJsonValue> {
schemaVersion: number;
type: string;
payload: TPayload;
}
export function serializeProjectCommand(
command: SerializedProjectCommand<unknown>
): string {
assertSerializedProjectCommand(command);
return JSON.stringify(command);
}
export function deserializeProjectCommand(
serialized: string
): SerializedProjectCommand<ProjectCommandJsonValue> {
const parsed: unknown = JSON.parse(serialized);
assertSerializedProjectCommand(parsed);
return parsed;
}
export function assertSerializedProjectCommand(
value: unknown
): asserts value is SerializedProjectCommand {
if (!isPlainObject(value)) {
throw new Error("Project command must be an object.");
}
if (
!Number.isSafeInteger(value.schemaVersion) ||
(value.schemaVersion as number) < 1
) {
throw new Error("Project command schema version must be a positive integer.");
}
if (typeof value.type !== "string" || !value.type.trim()) {
throw new Error("Project command type is required.");
}
assertJsonValue(value.payload, new WeakSet<object>());
}
function assertJsonValue(value: unknown, parents: WeakSet<object>): void {
if (
value === null ||
typeof value === "string" ||
typeof value === "boolean"
) {
return;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new Error("Project command numbers must be finite.");
}
return;
}
if (typeof value !== "object") {
throw new Error("Project command payload must contain JSON values only.");
}
if (parents.has(value)) {
throw new Error("Project command payload must not contain cycles.");
}
parents.add(value);
if (Array.isArray(value)) {
for (const entry of value) {
assertJsonValue(entry, parents);
}
} else {
if (!isPlainObject(value)) {
throw new Error("Project command payload must contain plain objects only.");
}
for (const entry of Object.values(value)) {
assertJsonValue(entry, parents);
}
}
parents.delete(value);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object") {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
@@ -0,0 +1,26 @@
import type { CircuitDeviceRowMoveHistoryProjectCommand } from "../models/circuit-device-row-move-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitDeviceRowMoveCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitDeviceRowMoveHistoryProjectCommand;
}
export interface ExecutedCircuitDeviceRowMoveCommand {
revision: AppendedProjectRevision;
inverse: CircuitDeviceRowMoveHistoryProjectCommand;
}
export interface CircuitDeviceRowMoveProjectCommandStore {
execute(
input: ExecuteCircuitDeviceRowMoveCommandInput
): ExecutedCircuitDeviceRowMoveCommand;
}
@@ -0,0 +1,26 @@
import type { CircuitDeviceRowUpdateProjectCommand } from "../models/circuit-device-row-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitDeviceRowUpdateCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitDeviceRowUpdateProjectCommand;
}
export interface ExecutedCircuitDeviceRowUpdateCommand {
revision: AppendedProjectRevision;
inverse: CircuitDeviceRowUpdateProjectCommand;
}
export interface CircuitDeviceRowProjectCommandStore {
executeUpdate(
input: ExecuteCircuitDeviceRowUpdateCommandInput
): ExecutedCircuitDeviceRowUpdateCommand;
}
@@ -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;
}
@@ -0,0 +1,26 @@
import type { CircuitUpdateProjectCommand } from "../models/circuit-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitUpdateCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitUpdateProjectCommand;
}
export interface ExecutedCircuitUpdateCommand {
revision: AppendedProjectRevision;
inverse: CircuitUpdateProjectCommand;
}
export interface CircuitProjectCommandStore {
executeUpdate(
input: ExecuteCircuitUpdateCommandInput
): ExecutedCircuitUpdateCommand;
}
@@ -0,0 +1,26 @@
import type { CircuitStructureProjectCommand } from "../models/circuit-structure-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitStructureCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitStructureProjectCommand;
}
export interface ExecutedCircuitStructureCommand {
revision: AppendedProjectRevision;
inverse: CircuitStructureProjectCommand;
}
export interface CircuitStructureProjectCommandStore {
execute(
input: ExecuteCircuitStructureCommandInput
): ExecutedCircuitStructureCommand;
}
@@ -0,0 +1,33 @@
import type { AppendedProjectRevision } from "./project-revision.store.js";
import type { ProjectHistoryState } from "./project-history.store.js";
export interface ExecuteUserProjectCommandInput {
projectId: string;
expectedRevision: number;
description?: string;
actorId?: string;
command: unknown;
}
export interface ExecuteProjectHistoryCommandInput {
projectId: string;
expectedRevision: number;
actorId?: string;
}
export interface ExecutedProjectCommand {
revision: AppendedProjectRevision;
history: ProjectHistoryState;
}
export interface ProjectCommandExecutor {
executeUser(
input: ExecuteUserProjectCommandInput
): ExecutedProjectCommand;
undo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand;
redo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand;
}
+25
View File
@@ -0,0 +1,25 @@
import type { SerializedProjectCommand } from "../models/project-command.model.js";
export type ProjectHistoryDirection = "undo" | "redo";
export interface ProjectHistoryState {
projectId: string;
currentRevision: number;
undoDepth: number;
redoDepth: number;
undoChangeSetId: string | null;
redoChangeSetId: string | null;
}
export interface ProjectHistoryCommand {
changeSetId: string;
command: SerializedProjectCommand;
}
export interface ProjectHistoryStore {
getState(projectId: string): ProjectHistoryState | null;
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
): ProjectHistoryCommand | null;
}
@@ -0,0 +1,31 @@
import type { SerializedProjectCommand } from "../models/project-command.model.js";
export type ProjectRevisionSource =
| "user"
| "undo"
| "redo"
| "restore"
| "migration";
export interface AppendProjectRevisionInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
forward: SerializedProjectCommand<unknown>;
inverse: SerializedProjectCommand<unknown>;
}
export interface AppendedProjectRevision {
revisionId: string;
changeSetId: string;
projectId: string;
revisionNumber: number;
createdAtIso: string;
}
export interface ProjectRevisionStore {
getCurrentRevision(projectId: string): number | null;
append(input: AppendProjectRevisionInput): AppendedProjectRevision;
}
+5 -20
View File
@@ -17,11 +17,7 @@ import type {
UpdateCircuitInput,
} from "../../shared/validation/circuit.schemas.js";
import { CircuitNumberingService } from "./circuit-numbering.service.js";
import { projectDeviceSyncFields } from "../../shared/constants/project-device-sync-fields.js";
import {
parseOverriddenFields,
serializeOverriddenFields,
} from "./project-device-sync.service.js";
import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js";
export class CircuitWriteService {
private readonly circuitRepository: CircuitRepository;
@@ -256,21 +252,10 @@ export class CircuitWriteService {
throw new Error("Invalid device row id.");
}
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
let overriddenFields = input.overriddenFields;
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
const inputValues = input as Record<string, unknown>;
const currentValues = current as unknown as Record<string, unknown>;
for (const field of projectDeviceSyncFields) {
if (
Object.prototype.hasOwnProperty.call(input, field) &&
(inputValues[field] ?? null) !== (currentValues[field] ?? null)
) {
overrides.add(field);
}
}
overriddenFields = serializeOverriddenFields(overrides);
}
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
current,
input
);
await this.deviceRowRepository.updateFields(rowId, {
...input,
...(overriddenFields !== undefined ? { overriddenFields } : {}),
@@ -0,0 +1,206 @@
import { ProjectHistoryOperationUnavailableError } from "../errors/project-history-operation-unavailable.error.js";
import {
assertCircuitDeviceRowMoveProjectCommand,
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand,
circuitDeviceRowMoveCommandType,
circuitDeviceRowMoveWithNewCircuitCommandType,
} from "../models/circuit-device-row-move-project-command.model.js";
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,
} from "../models/circuit-project-command.model.js";
import {
assertCircuitDeleteProjectCommand,
assertCircuitInsertProjectCommand,
circuitDeleteCommandType,
circuitInsertCommandType,
} from "../models/circuit-structure-project-command.model.js";
import {
assertSerializedProjectCommand,
type SerializedProjectCommand,
} from "../models/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";
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
import type {
ExecuteProjectHistoryCommandInput,
ExecutedProjectCommand,
ExecuteUserProjectCommandInput,
ProjectCommandExecutor,
} from "../ports/project-command.executor.js";
import type {
ProjectHistoryDirection,
ProjectHistoryStore,
} from "../ports/project-history.store.js";
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
interface DispatchProjectCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: SerializedProjectCommand;
}
export class ProjectCommandService implements ProjectCommandExecutor {
constructor(
private readonly circuitStore: CircuitProjectCommandStore,
private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore,
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
executeUser(
input: ExecuteUserProjectCommandInput
): ExecutedProjectCommand {
assertExpectedRevision(input.expectedRevision);
assertSerializedProjectCommand(input.command);
return this.dispatch({
...input,
source: "user",
command: input.command,
});
}
undo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand {
return this.executeHistory(input, "undo");
}
redo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand {
return this.executeHistory(input, "redo");
}
private executeHistory(
input: ExecuteProjectHistoryCommandInput,
direction: ProjectHistoryDirection
) {
assertExpectedRevision(input.expectedRevision);
const step = this.historyStore.getNextCommand(
input.projectId,
direction
);
if (!step) {
throw new ProjectHistoryOperationUnavailableError(
input.projectId,
direction
);
}
return this.dispatch({
...input,
source: direction,
description: `${direction === "undo" ? "Undo" : "Redo"} ${step.command.type}`,
historyTargetChangeSetId: step.changeSetId,
command: step.command,
});
}
private dispatch(
input: DispatchProjectCommandInput
): ExecutedProjectCommand {
const revision = this.executeTypedCommand(input);
const history = this.historyStore.getState(input.projectId);
if (!history) {
throw new Error("Project history disappeared after command execution.");
}
return { revision, history };
}
private executeTypedCommand(input: DispatchProjectCommandInput) {
switch (input.command.type) {
case circuitUpdateCommandType: {
assertCircuitUpdateProjectCommand(input.command);
return this.circuitStore.executeUpdate({
...input,
command: input.command,
}).revision;
}
case circuitDeviceRowUpdateCommandType: {
assertCircuitDeviceRowUpdateProjectCommand(input.command);
return this.deviceRowStore.executeUpdate({
...input,
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;
}
case circuitDeviceRowMoveCommandType: {
assertCircuitDeviceRowMoveProjectCommand(input.command);
return this.deviceRowMoveStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitDeviceRowMoveWithNewCircuitCommandType: {
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
input.command
);
return this.deviceRowMoveStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitInsertCommandType: {
assertCircuitInsertProjectCommand(input.command);
return this.circuitStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitDeleteCommandType: {
assertCircuitDeleteProjectCommand(input.command);
return this.circuitStructureStore.execute({
...input,
command: input.command,
}).revision;
}
default:
throw new Error(
`Unsupported project command type: ${input.command.type}.`
);
}
}
}
function assertExpectedRevision(expectedRevision: number) {
if (
!Number.isSafeInteger(expectedRevision) ||
expectedRevision < 0
) {
throw new Error(
"Expected project revision must be a non-negative integer."
);
}
}
@@ -0,0 +1,57 @@
import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
} from "../../shared/constants/project-device-sync-fields.js";
export function parseOverriddenFields(
value: string | null | undefined
): ProjectDeviceSyncField[] {
if (!value) {
return [];
}
try {
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((field): field is ProjectDeviceSyncField =>
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
);
} catch {
return [];
}
}
export function serializeOverriddenFields(
fields: Iterable<ProjectDeviceSyncField>
): string | undefined {
const fieldSet = new Set(fields);
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
return unique.length > 0 ? JSON.stringify(unique) : undefined;
}
export function deriveOverriddenFieldsForLocalEdit(
current: {
linkedProjectDeviceId: string | null | undefined;
overriddenFields: string | null | undefined;
} & Partial<Record<ProjectDeviceSyncField, unknown>>,
patch: Partial<Record<ProjectDeviceSyncField | "overriddenFields", unknown>>
): string | null | undefined {
if (
!current.linkedProjectDeviceId ||
Object.prototype.hasOwnProperty.call(patch, "overriddenFields")
) {
return patch.overriddenFields as string | null | undefined;
}
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
for (const field of projectDeviceSyncFields) {
if (
Object.prototype.hasOwnProperty.call(patch, field) &&
(patch[field] ?? null) !== (current[field] ?? null)
) {
overrides.add(field);
}
}
return serializeOverriddenFields(overrides);
}
@@ -5,6 +5,15 @@ import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
} from "../../shared/constants/project-device-sync-fields.js";
import {
parseOverriddenFields,
serializeOverriddenFields,
} from "./project-device-overrides.js";
export {
parseOverriddenFields,
serializeOverriddenFields,
} from "./project-device-overrides.js";
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>;
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
@@ -25,29 +34,6 @@ export interface ProjectDeviceSyncRestoreRow {
overriddenFields: ProjectDeviceSyncField[];
}
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
if (!value) {
return [];
}
try {
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((field): field is ProjectDeviceSyncField =>
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
);
} catch {
return [];
}
}
export function serializeOverriddenFields(fields: Iterable<ProjectDeviceSyncField>): string | undefined {
const fieldSet = new Set(fields);
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
return unique.length > 0 ? JSON.stringify(unique) : undefined;
}
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
return projectDevice[field];
}
+27
View File
@@ -3,6 +3,33 @@ export interface ProjectDto {
name: string;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
currentRevision: number;
}
export interface ProjectHistoryStateDto {
projectId: string;
currentRevision: number;
undoDepth: number;
redoDepth: number;
undoChangeSetId: string | null;
redoChangeSetId: string | null;
}
export interface ProjectCommandDto {
schemaVersion: number;
type: string;
payload: unknown;
}
export interface ProjectCommandResultDto {
revision: {
revisionId: string;
changeSetId: string;
projectId: string;
revisionNumber: number;
createdAtIso: string;
};
history: ProjectHistoryStateDto;
}
export interface DistributionBoardDto {
+64
View File
@@ -7,7 +7,10 @@ import type {
DistributionBoardDto,
FloorDto,
GlobalDeviceDto,
ProjectCommandDto,
ProjectCommandResultDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
ProjectDeviceDisconnectResultDto,
ProjectDeviceSyncPreviewDto,
ProjectDeviceSyncRestoreRowDto,
@@ -54,6 +57,67 @@ export function getProject(projectId: string) {
return request<ProjectDto>(`/api/projects/${projectId}`);
}
export function getProjectHistory(projectId: string) {
return request<ProjectHistoryStateDto>(
`/api/projects/${projectId}/history`
);
}
export function executeProjectCommand(
projectId: string,
expectedRevision: number,
command: ProjectCommandDto,
description?: string
) {
return request<ProjectCommandResultDto>(
`/api/projects/${projectId}/commands`,
{
method: "POST",
body: JSON.stringify({
expectedRevision,
command,
...(description ? { description } : {}),
}),
}
);
}
export function undoProjectCommand(
projectId: string,
expectedRevision: number
) {
return executeProjectHistoryCommand(
projectId,
expectedRevision,
"undo"
);
}
export function redoProjectCommand(
projectId: string,
expectedRevision: number
) {
return executeProjectHistoryCommand(
projectId,
expectedRevision,
"redo"
);
}
function executeProjectHistoryCommand(
projectId: string,
expectedRevision: number,
direction: "undo" | "redo"
) {
return request<ProjectCommandResultDto>(
`/api/projects/${projectId}/history/${direction}`,
{
method: "POST",
body: JSON.stringify({ expectedRevision }),
}
);
}
export function createProject(name: string) {
return request<ProjectDto>("/api/projects", {
method: "POST",
@@ -0,0 +1,27 @@
import { db } from "../../db/client.js";
import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitDeviceRowMoveProjectCommandRepository } from "../../db/repositories/circuit-device-row-move-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 { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
export const circuitDeviceRowProjectCommandStore =
new CircuitDeviceRowProjectCommandRepository(db);
export const circuitDeviceRowStructureProjectCommandStore =
new CircuitDeviceRowStructureProjectCommandRepository(db);
export const circuitDeviceRowMoveProjectCommandStore =
new CircuitDeviceRowMoveProjectCommandRepository(db);
export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore,
circuitDeviceRowStructureProjectCommandStore,
circuitDeviceRowMoveProjectCommandStore,
circuitStructureProjectCommandStore,
projectHistoryStore
);
@@ -0,0 +1,99 @@
import type { Request, Response } from "express";
import { ProjectHistoryOperationUnavailableError } from "../../domain/errors/project-history-operation-unavailable.error.js";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import {
executeProjectCommandSchema,
executeProjectHistoryCommandSchema,
} from "../../shared/validation/project-command.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
export function executeProjectCommand(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const parsed = executeProjectCommandSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
return res.json(
projectCommandService.executeUser({
projectId,
...parsed.data,
})
);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function undoProjectCommand(req: Request, res: Response) {
return executeHistoryCommand(req, res, "undo");
}
export function redoProjectCommand(req: Request, res: Response) {
return executeHistoryCommand(req, res, "redo");
}
function executeHistoryCommand(
req: Request,
res: Response,
direction: "undo" | "redo"
) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const parsed = executeProjectHistoryCommandSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const result = projectCommandService[direction]({
projectId,
...parsed.data,
});
return res.json(result);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
function getProjectId(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
res.status(400).json({ error: "Invalid projectId" });
return null;
}
return projectId;
}
export function respondWithProjectCommandError(
error: unknown,
res: Response
) {
if (error instanceof ProjectRevisionConflictError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_REVISION_CONFLICT",
expectedRevision: error.expectedRevision,
currentRevision: error.actualRevision,
});
}
if (error instanceof ProjectHistoryOperationUnavailableError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_HISTORY_OPERATION_UNAVAILABLE",
direction: error.direction,
});
}
return res.status(400).json({
error:
error instanceof Error
? error.message
: "Project command could not be executed.",
});
}
@@ -0,0 +1,15 @@
import type { Request, Response } from "express";
import { projectHistoryStore } from "../composition/project-command-stores.js";
export function getProjectHistory(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const state = projectHistoryStore.getState(projectId);
if (!state) {
return res.status(404).json({ error: "Project not found" });
}
return res.json(state);
}
+10
View File
@@ -13,12 +13,22 @@ import { listCircuitListsByProject } from "../controllers/circuit-list.controlle
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
import { getProjectHistory } from "../controllers/project-history.controller.js";
import {
executeProjectCommand,
redoProjectCommand,
undoProjectCommand,
} from "../controllers/project-command.controller.js";
export const projectRouter = Router();
projectRouter.get("/", listProjects);
projectRouter.post("/", createProject);
projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.post("/:projectId/commands", executeProjectCommand);
projectRouter.post("/:projectId/history/undo", undoProjectCommand);
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
@@ -0,0 +1,17 @@
import { z } from "zod";
const projectCommandEnvelopeSchema = z.object({
schemaVersion: z.number().int().positive(),
type: z.string().trim().min(1),
payload: z.unknown(),
});
export const executeProjectCommandSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
description: z.string().trim().min(1).max(500).optional(),
command: projectCommandEnvelopeSchema,
});
export const executeProjectHistoryCommandSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
});
@@ -0,0 +1,709 @@
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 { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-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 { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import {
createCircuitDeviceRowMoveProjectCommand,
createCircuitDeviceRowMoveWithNewCircuitProjectCommand,
} from "../src/domain/models/circuit-device-row-move-project-command.model.js";
import type { CircuitSnapshot } from "../src/domain/models/circuit-structure-project-command.model.js";
interface TestFixture {
context: DatabaseContext;
circuitListId: string;
sectionId: string;
}
function createTestDatabase(): TestFixture {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Other project" },
])
.run();
const 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-2",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F2",
sortOrder: 20,
isReserve: 0,
},
{
id: "circuit-3",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F3",
sortOrder: 30,
isReserve: 0,
},
{
id: "circuit-foreign",
circuitListId: foreignBoard.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 1,
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-1",
circuitId: "circuit-1",
sortOrder: 10,
name: "Quelle 1",
displayName: "Quelle 1",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
id: "row-retained",
circuitId: "circuit-1",
sortOrder: 20,
name: "Bleibt",
displayName: "Bleibt",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
id: "row-2",
circuitId: "circuit-2",
sortOrder: 10,
name: "Quelle 2",
displayName: "Quelle 2",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
{
id: "row-target",
circuitId: "circuit-3",
sortOrder: 10,
name: "Ziel",
displayName: "Ziel",
quantity: 1,
powerPerUnit: 0.3,
simultaneityFactor: 1,
},
])
.run();
return {
context,
circuitListId: board.id,
sectionId: section.id,
};
}
function getRow(context: DatabaseContext, rowId: string) {
return context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, rowId))
.get();
}
function isReserve(context: DatabaseContext, circuitId: string) {
return Boolean(
context.db
.select({ isReserve: circuits.isReserve })
.from(circuits)
.where(eq(circuits.id, circuitId))
.get()?.isReserve
);
}
function createMoveTargetSnapshot(
fixture: TestFixture,
overrides: Partial<CircuitSnapshot> = {}
): CircuitSnapshot {
return {
id: "circuit-new",
circuitListId: fixture.circuitListId,
sectionId: fixture.sectionId,
equipmentIdentifier: "-1F4",
displayName: "Neuer Stromkreis",
sortOrder: 40,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: null,
controlRequirement: null,
status: null,
isReserve: true,
remark: null,
deviceRows: [],
...overrides,
};
}
describe("circuit device-row move project-command repository", () => {
it("moves multiple rows and supports persisted undo and redo", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitDeviceRowMoveProjectCommandRepository(
fixture.context.db
);
const command = createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: "circuit-3",
targetSortOrder: 20,
},
{
rowId: "row-2",
expectedCircuitId: "circuit-2",
expectedSortOrder: 10,
targetCircuitId: "circuit-3",
targetSortOrder: 30,
},
]);
const moved = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
[
getRow(fixture.context, "row-1"),
getRow(fixture.context, "row-2"),
].map((row) => [row?.circuitId, row?.sortOrder]),
[
["circuit-3", 20],
["circuit-3", 30],
]
);
assert.equal(isReserve(fixture.context, "circuit-1"), false);
assert.equal(isReserve(fixture.context, "circuit-2"), true);
assert.equal(isReserve(fixture.context, "circuit-3"), false);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: moved.revision.changeSetId,
command: moved.inverse,
});
assert.deepEqual(
[
getRow(fixture.context, "row-1"),
getRow(fixture.context, "row-2"),
].map((row) => [row?.circuitId, row?.sortOrder]),
[
["circuit-1", 10],
["circuit-2", 10],
]
);
assert.equal(isReserve(fixture.context, "circuit-1"), false);
assert.equal(isReserve(fixture.context, "circuit-2"), false);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: moved.revision.changeSetId,
command,
});
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: moved.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
fixture.context.close();
}
});
it("reorders a row inside its current circuit", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitDeviceRowMoveProjectCommandRepository(
fixture.context.db
);
const moved = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-retained",
expectedCircuitId: "circuit-1",
expectedSortOrder: 20,
targetCircuitId: "circuit-1",
targetSortOrder: 5,
},
]),
});
assert.equal(
getRow(fixture.context, "row-retained")?.sortOrder,
5
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: moved.revision.changeSetId,
command: moved.inverse,
});
assert.equal(
getRow(fixture.context, "row-retained")?.sortOrder,
20
);
} finally {
fixture.context.close();
}
});
it("rejects stale row positions and cross-project targets", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitDeviceRowMoveProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 999,
targetCircuitId: "circuit-3",
targetSortOrder: 20,
},
]),
}),
/changed before move/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: "circuit-foreign",
targetSortOrder: 10,
},
]),
}),
/does not belong to project/
);
assert.deepEqual(
[
getRow(fixture.context, "row-1")?.circuitId,
getRow(fixture.context, "row-1")?.sortOrder,
],
["circuit-1", 10]
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back rows and reserve states for late history failures", () => {
const fixture = createTestDatabase();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_move_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced move history failure');
END;
`);
const store = new CircuitDeviceRowMoveProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-2",
expectedCircuitId: "circuit-2",
expectedSortOrder: 10,
targetCircuitId: "circuit-3",
targetSortOrder: 20,
},
]),
}),
/forced move history failure/
);
assert.deepEqual(
[
getRow(fixture.context, "row-2")?.circuitId,
getRow(fixture.context, "row-2")?.sortOrder,
],
["circuit-2", 10]
);
assert.equal(isReserve(fixture.context, "circuit-2"), false);
assert.equal(isReserve(fixture.context, "circuit-3"), false);
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back a move for a stale project revision", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitDeviceRowMoveProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-2",
expectedCircuitId: "circuit-2",
expectedSortOrder: 10,
targetCircuitId: "circuit-3",
targetSortOrder: 20,
},
]),
}),
/at revision 0, expected 1/
);
assert.equal(
getRow(fixture.context, "row-2")?.circuitId,
"circuit-2"
);
assert.equal(isReserve(fixture.context, "circuit-2"), false);
} finally {
fixture.context.close();
}
});
it("creates one target circuit and supports persisted undo and redo", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitDeviceRowMoveProjectCommandRepository(
fixture.context.db
);
const targetCircuit = createMoveTargetSnapshot(fixture);
const command =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
targetCircuit,
[
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: targetCircuit.id,
targetSortOrder: 10,
},
{
rowId: "row-2",
expectedCircuitId: "circuit-2",
expectedSortOrder: 10,
targetCircuitId: targetCircuit.id,
targetSortOrder: 20,
},
]
);
const moved = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
[
getRow(fixture.context, "row-1"),
getRow(fixture.context, "row-2"),
].map((row) => [row?.circuitId, row?.sortOrder]),
[
["circuit-new", 10],
["circuit-new", 20],
]
);
assert.equal(
fixture.context.db
.select({ equipmentIdentifier: circuits.equipmentIdentifier })
.from(circuits)
.where(eq(circuits.id, "circuit-new"))
.get()?.equipmentIdentifier,
"-1F4"
);
assert.equal(isReserve(fixture.context, "circuit-new"), false);
assert.equal(isReserve(fixture.context, "circuit-1"), false);
assert.equal(isReserve(fixture.context, "circuit-2"), true);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: moved.revision.changeSetId,
command: moved.inverse,
});
assert.equal(
fixture.context.db
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, "circuit-new"))
.get(),
undefined
);
assert.deepEqual(
[
getRow(fixture.context, "row-1"),
getRow(fixture.context, "row-2"),
].map((row) => [row?.circuitId, row?.sortOrder]),
[
["circuit-1", 10],
["circuit-2", 10],
]
);
assert.equal(isReserve(fixture.context, "circuit-1"), false);
assert.equal(isReserve(fixture.context, "circuit-2"), false);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: moved.revision.changeSetId,
command,
});
assert.equal(
getRow(fixture.context, "row-2")?.circuitId,
"circuit-new"
);
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: moved.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
fixture.context.close();
}
});
it("rejects deletion when the created target changed outside history", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitDeviceRowMoveProjectCommandRepository(
fixture.context.db
);
const targetCircuit = createMoveTargetSnapshot(fixture);
const moved = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
targetCircuit,
[
{
rowId: "row-2",
expectedCircuitId: "circuit-2",
expectedSortOrder: 10,
targetCircuitId: targetCircuit.id,
targetSortOrder: 10,
},
]
),
});
fixture.context.db
.update(circuits)
.set({ displayName: "Außerhalb geändert" })
.where(eq(circuits.id, targetCircuit.id))
.run();
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
moved.revision.changeSetId,
command: moved.inverse,
}),
/target circuit changed/
);
assert.equal(
getRow(fixture.context, "row-2")?.circuitId,
targetCircuit.id
);
assert.ok(
fixture.context.db
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, targetCircuit.id))
.get()
);
assert.equal(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
)?.currentRevision,
1
);
} finally {
fixture.context.close();
}
});
it("rolls back target creation and moves for late history failures", () => {
const fixture = createTestDatabase();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_composite_move_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced composite move history failure');
END;
`);
const targetCircuit = createMoveTargetSnapshot(fixture);
const store = new CircuitDeviceRowMoveProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
targetCircuit,
[
{
rowId: "row-2",
expectedCircuitId: "circuit-2",
expectedSortOrder: 10,
targetCircuitId: targetCircuit.id,
targetSortOrder: 10,
},
]
),
}),
/forced composite move history failure/
);
assert.equal(
fixture.context.db
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, targetCircuit.id))
.get(),
undefined
);
assert.equal(
getRow(fixture.context, "row-2")?.circuitId,
"circuit-2"
);
assert.equal(isReserve(fixture.context, "circuit-2"), false);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
});
@@ -0,0 +1,380 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
import { deserializeProjectCommand } from "../src/domain/models/project-command.model.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Other project" },
])
.run();
const boards = new DistributionBoardRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections(
"project-1",
"UV-01"
);
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
"project-2",
"UV-02"
);
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
const foreignSection = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, foreignBoard.id))
.get();
assert.ok(section);
assert.ok(foreignSection);
context.db
.insert(circuits)
.values([
{
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
{
id: "circuit-2",
circuitListId: foreignBoard.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
])
.run();
context.db
.insert(projectDevices)
.values([
{
id: "project-device-1",
projectId: "project-1",
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
id: "project-device-2",
projectId: "project-2",
name: "Fremdgerät",
displayName: "Fremdgerät",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
])
.run();
context.db
.insert(rooms)
.values([
{
id: "room-1",
projectId: "project-1",
floorId: null,
roomNumber: "001",
roomName: "Büro",
},
{
id: "room-2",
projectId: "project-2",
floorId: null,
roomNumber: "999",
roomName: "Fremdraum",
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte lokal",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
id: "row-2",
circuitId: "circuit-2",
sortOrder: 10,
name: "Fremdzeile",
displayName: "Fremdzeile",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
])
.run();
return context;
}
function getRow(context: DatabaseContext) {
const row = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get();
assert.ok(row);
return row;
}
describe("circuit device-row project-command repository", () => {
it("commits the row update, tracked override, inverse and revision together", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
const executed = store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
description: "Gerätezeile bearbeiten",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
roomId: "room-1",
remark: null,
}),
});
const row = getRow(context);
assert.equal(row.quantity, 2);
assert.equal(row.roomId, "room-1");
assert.equal(row.remark, null);
assert.equal(row.overriddenFields, "[\"quantity\"]");
assert.equal(executed.revision.revisionNumber, 1);
assert.deepEqual(executed.inverse.payload, {
rowId: "row-1",
changes: [
{ field: "quantity", value: 1 },
{ field: "roomId", value: null },
{ field: "remark", value: null },
{ field: "overriddenFields", value: null },
],
});
const changeSet = context.db.select().from(projectChangeSets).get();
assert.ok(changeSet);
const storedForward = deserializeProjectCommand(
changeSet.forwardPayloadJson
);
assert.deepEqual(storedForward, {
schemaVersion: 1,
type: "circuit-device-row.update",
payload: {
rowId: "row-1",
changes: [
{ field: "quantity", value: 2 },
{ field: "roomId", value: "room-1" },
{ field: "remark", value: null },
{ field: "overriddenFields", value: "[\"quantity\"]" },
],
},
});
} finally {
context.close();
}
});
it("applies the generated inverse and restores override metadata", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
const forward = store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 3,
}),
});
const undone = store.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: forward.revision.changeSetId,
command: forward.inverse,
});
const row = getRow(context);
assert.equal(row.quantity, 1);
assert.equal(row.overriddenFields, null);
assert.equal(undone.revision.revisionNumber, 2);
assert.deepEqual(
context.db
.select({ source: projectRevisions.source })
.from(projectRevisions)
.all()
.map((revision) => revision.source),
["user", "undo"]
);
} finally {
context.close();
}
});
it("rolls back the row and override update for a stale revision", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
}),
});
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 4,
}),
}),
(error) =>
error instanceof ProjectRevisionConflictError &&
error.actualRevision === 1
);
const row = getRow(context);
assert.equal(row.quantity, 2);
assert.equal(row.overriddenFields, "[\"quantity\"]");
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
} finally {
context.close();
}
});
it("rolls back the row update when late history persistence fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_device_row_command_change_set
BEFORE INSERT ON project_change_sets
BEGIN
SELECT RAISE(ABORT, 'forced device-row history failure');
END;
`);
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 5,
}),
}),
/forced device-row history failure/
);
const row = getRow(context);
assert.equal(row.quantity, 1);
assert.equal(row.overriddenFields, null);
assert.equal(
context.db
.select()
.from(projects)
.where(eq(projects.id, "project-1"))
.get()?.currentRevision,
0
);
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
} finally {
context.close();
}
});
it("rejects cross-project rows, devices and rooms without history", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-2", {
quantity: 2,
}),
}),
/does not belong to project/
);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
linkedProjectDeviceId: "project-device-2",
}),
}),
/Invalid linked project device/
);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
roomId: "room-2",
}),
}),
/Invalid room/
);
assert.equal(getRow(context).linkedProjectDeviceId, "project-device-1");
assert.equal(getRow(context).roomId, null);
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
} finally {
context.close();
}
});
});
@@ -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();
}
});
});
@@ -0,0 +1,317 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projects } from "../src/db/schema/projects.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
import { deserializeProjectCommand } from "../src/domain/models/project-command.model.js";
interface TestFixture {
context: DatabaseContext;
circuitListId: string;
sectionId: string;
foreignSectionId: string;
}
function createTestFixture(): TestFixture {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Other project" },
])
.run();
const repository = new DistributionBoardRepository(context.db);
const board = repository.createWithCircuitListAndDefaultSections(
"project-1",
"UV-01"
);
const foreignBoard = repository.createWithCircuitListAndDefaultSections(
"project-2",
"UV-02"
);
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
const foreignSection = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, foreignBoard.id))
.get();
assert.ok(section);
assert.ok(foreignSection);
context.db
.insert(circuits)
.values([
{
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Licht Bestand",
sortOrder: 10,
protectionRatedCurrent: 10,
isReserve: 0,
},
{
id: "circuit-2",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F2",
displayName: "Zweiter Stromkreis",
sortOrder: 20,
isReserve: 0,
},
])
.run();
return {
context,
circuitListId: board.id,
sectionId: section.id,
foreignSectionId: foreignSection.id,
};
}
function getCircuit(context: DatabaseContext) {
const circuit = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(circuit);
return circuit;
}
describe("circuit project-command repository", () => {
it("commits a circuit update, inverse command and revision together", () => {
const fixture = createTestFixture();
try {
const store = new CircuitProjectCommandRepository(fixture.context.db);
const command = createCircuitUpdateProjectCommand("circuit-1", {
displayName: null,
protectionRatedCurrent: 16,
isReserve: true,
});
const executed = store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
description: "Stromkreis bearbeiten",
command,
});
const circuit = getCircuit(fixture.context);
assert.equal(circuit.displayName, null);
assert.equal(circuit.protectionRatedCurrent, 16);
assert.equal(circuit.isReserve, 1);
assert.equal(executed.revision.revisionNumber, 1);
assert.deepEqual(executed.inverse.payload, {
circuitId: "circuit-1",
changes: [
{ field: "displayName", value: "Licht Bestand" },
{ field: "protectionRatedCurrent", value: 10 },
{ field: "isReserve", value: false },
],
});
const changeSet = fixture.context.db
.select()
.from(projectChangeSets)
.get();
assert.ok(changeSet);
assert.deepEqual(deserializeProjectCommand(changeSet.forwardPayloadJson), command);
assert.deepEqual(
deserializeProjectCommand(changeSet.inversePayloadJson),
executed.inverse
);
assert.equal(
fixture.context.db
.select()
.from(projects)
.where(eq(projects.id, "project-1"))
.get()?.currentRevision,
1
);
} finally {
fixture.context.close();
}
});
it("can apply the generated inverse as a new auditable revision", () => {
const fixture = createTestFixture();
try {
const store = new CircuitProjectCommandRepository(fixture.context.db);
const forward = store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Licht Neu",
protectionRatedCurrent: 16,
}),
});
const undone = store.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: forward.revision.changeSetId,
description: "Stromkreisänderung rückgängig machen",
command: forward.inverse,
});
const circuit = getCircuit(fixture.context);
assert.equal(circuit.displayName, "Licht Bestand");
assert.equal(circuit.protectionRatedCurrent, 10);
assert.equal(undone.revision.revisionNumber, 2);
assert.deepEqual(
fixture.context.db
.select({ source: projectRevisions.source })
.from(projectRevisions)
.all()
.map((revision) => revision.source),
["user", "undo"]
);
} finally {
fixture.context.close();
}
});
it("rolls back the circuit update when the expected revision is stale", () => {
const fixture = createTestFixture();
try {
const store = new CircuitProjectCommandRepository(fixture.context.db);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Erste Änderung",
}),
});
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Veraltete Änderung",
}),
}),
(error) =>
error instanceof ProjectRevisionConflictError &&
error.actualRevision === 1
);
assert.equal(getCircuit(fixture.context).displayName, "Erste Änderung");
assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 1);
assert.equal(fixture.context.db.select().from(projectChangeSets).all().length, 1);
} finally {
fixture.context.close();
}
});
it("rolls back the circuit update when late history persistence fails", () => {
const fixture = createTestFixture();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_circuit_command_change_set
BEFORE INSERT ON project_change_sets
BEGIN
SELECT RAISE(ABORT, 'forced command history failure');
END;
`);
const store = new CircuitProjectCommandRepository(fixture.context.db);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Darf nicht bleiben",
}),
}),
/forced command history failure/
);
assert.equal(getCircuit(fixture.context).displayName, "Licht Bestand");
assert.equal(
fixture.context.db
.select()
.from(projects)
.where(eq(projects.id, "project-1"))
.get()?.currentRevision,
0
);
assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 0);
assert.equal(fixture.context.db.select().from(projectChangeSets).all().length, 0);
} finally {
fixture.context.close();
}
});
it("rejects foreign sections and duplicate equipment identifiers before history", () => {
const fixture = createTestFixture();
try {
const store = new CircuitProjectCommandRepository(fixture.context.db);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
sectionId: fixture.foreignSectionId,
}),
}),
/Section does not belong/
);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
equipmentIdentifier: "-1F2",
}),
}),
/Duplicate equipmentIdentifier/
);
const circuit = getCircuit(fixture.context);
assert.equal(circuit.sectionId, fixture.sectionId);
assert.equal(circuit.equipmentIdentifier, "-1F1");
assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 0);
} finally {
fixture.context.close();
}
});
});
@@ -0,0 +1,565 @@
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 { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-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 type { CircuitDeviceRowSnapshot } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import {
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
type CircuitSnapshot,
} from "../src/domain/models/circuit-structure-project-command.model.js";
interface TestFixture {
context: DatabaseContext;
circuitListId: string;
sectionId: string;
foreignCircuitListId: string;
foreignSectionId: string;
}
function createTestDatabase(): TestFixture {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Other project" },
])
.run();
const 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: "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(circuits)
.values({
id: "circuit-existing",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Bestand",
sortOrder: 10,
protectionType: "LS",
protectionRatedCurrent: 16,
protectionCharacteristic: "B",
cableType: "NYM-J",
cableCrossSection: "3x1,5",
cableLength: 18.5,
rcdAssignment: "FI-1",
terminalDesignation: "X1",
voltage: 230,
controlRequirement: "DALI",
status: "planned",
isReserve: 0,
remark: "Vollständig",
})
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-existing-1",
circuitId: "circuit-existing",
linkedProjectDeviceId: "device-1",
legacyConsumerId: "legacy-1",
sortOrder: 10,
name: "Leuchte A",
displayName: "Leuchte A lokal",
phaseType: "single_phase",
category: "lighting",
roomId: "room-1",
roomNumberSnapshot: "001",
roomNameSnapshot: "Büro",
quantity: 2,
powerPerUnit: 0.1,
simultaneityFactor: 0.8,
cosPhi: 0.9,
overriddenFields: "[\"displayName\"]",
},
{
id: "row-existing-2",
circuitId: "circuit-existing",
sortOrder: 20,
name: "Leuchte B",
displayName: "Leuchte B",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
])
.run();
return {
context,
circuitListId: board.id,
sectionId: section.id,
foreignCircuitListId: foreignBoard.id,
foreignSectionId: foreignSection.id,
};
}
function createRow(
circuitId: string,
id: string,
sortOrder: number,
overrides: Partial<CircuitDeviceRowSnapshot> = {}
): CircuitDeviceRowSnapshot {
return {
id,
circuitId,
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder,
name: `Gerät ${id}`,
displayName: `Gerät ${id}`,
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: null,
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
overriddenFields: null,
...overrides,
};
}
function createCircuitSnapshot(
fixture: TestFixture,
overrides: Partial<CircuitSnapshot> = {}
): CircuitSnapshot {
const id = overrides.id ?? "circuit-new";
const deviceRows =
overrides.deviceRows ??
[
createRow(id, "row-new-1", 10, {
linkedProjectDeviceId: "device-1",
roomId: "room-1",
}),
createRow(id, "row-new-2", 20),
];
return {
id,
circuitListId: fixture.circuitListId,
sectionId: fixture.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Neuer Stromkreis",
sortOrder: 20,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: null,
status: null,
isReserve: deviceRows.length === 0,
remark: null,
deviceRows,
...overrides,
};
}
function getCircuit(
context: DatabaseContext,
circuitId: string
) {
return context.db
.select()
.from(circuits)
.where(eq(circuits.id, circuitId))
.get();
}
function getRows(context: DatabaseContext, circuitId: string) {
return context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuitId))
.orderBy(
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
}
describe("circuit structure project-command repository", () => {
it("inserts a multi-device circuit and supports persisted undo and redo", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const command = createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture)
);
const inserted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(getCircuit(fixture.context, "circuit-new")?.isReserve, 0);
assert.deepEqual(
getRows(fixture.context, "circuit-new").map((row) => row.id),
["row-new-1", "row-new-2"]
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(getCircuit(fixture.context, "circuit-new"), undefined);
assert.equal(getRows(fixture.context, "circuit-new").length, 0);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command,
});
assert.deepEqual(
getRows(fixture.context, "circuit-new").map((row) => row.id),
["row-new-1", "row-new-2"]
);
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: inserted.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
fixture.context.close();
}
});
it("inserts and removes an empty reserve circuit", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const inserted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture, {
id: "circuit-reserve",
equipmentIdentifier: "-1F3",
deviceRows: [],
isReserve: true,
})
),
});
assert.equal(
getCircuit(fixture.context, "circuit-reserve")?.isReserve,
1
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(
getCircuit(fixture.context, "circuit-reserve"),
undefined
);
} finally {
fixture.context.close();
}
});
it("deletes and restores a complete multi-device circuit block", () => {
const fixture = createTestDatabase();
try {
const beforeCircuit = getCircuit(
fixture.context,
"circuit-existing"
);
const beforeRows = getRows(fixture.context, "circuit-existing");
assert.ok(beforeCircuit);
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const deleted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeleteProjectCommand(
"circuit-existing",
fixture.circuitListId
),
});
assert.equal(
getCircuit(fixture.context, "circuit-existing"),
undefined
);
assert.equal(getRows(fixture.context, "circuit-existing").length, 0);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: deleted.revision.changeSetId,
command: deleted.inverse,
});
assert.deepEqual(
getCircuit(fixture.context, "circuit-existing"),
beforeCircuit
);
assert.deepEqual(
getRows(fixture.context, "circuit-existing"),
beforeRows
);
} finally {
fixture.context.close();
}
});
it("rolls back insert and delete for late history failures", () => {
const fixture = createTestDatabase();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_circuit_structure_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced circuit structure history failure');
END;
`);
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture)
),
}),
/forced circuit structure history failure/
);
assert.equal(getCircuit(fixture.context, "circuit-new"), undefined);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeleteProjectCommand(
"circuit-existing",
fixture.circuitListId
),
}),
/forced circuit structure history failure/
);
assert.ok(getCircuit(fixture.context, "circuit-existing"));
assert.equal(getRows(fixture.context, "circuit-existing").length, 2);
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back deletion for a stale expected revision", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitDeleteProjectCommand(
"circuit-existing",
fixture.circuitListId
),
}),
/at revision 0, expected 1/
);
assert.ok(getCircuit(fixture.context, "circuit-existing"));
assert.equal(getRows(fixture.context, "circuit-existing").length, 2);
} finally {
fixture.context.close();
}
});
it("rejects invalid ownership, identifiers, references and row ids", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const invalidSnapshots = [
createCircuitSnapshot(fixture, {
id: "foreign-list",
circuitListId: fixture.foreignCircuitListId,
sectionId: fixture.foreignSectionId,
}),
createCircuitSnapshot(fixture, {
id: "foreign-section",
sectionId: fixture.foreignSectionId,
}),
createCircuitSnapshot(fixture, {
id: "duplicate-bmk",
equipmentIdentifier: "-1F1",
}),
createCircuitSnapshot(fixture, {
id: "duplicate-row",
deviceRows: [
createRow("duplicate-row", "row-existing-1", 10),
],
}),
createCircuitSnapshot(fixture, {
id: "foreign-device",
deviceRows: [
createRow("foreign-device", "row-foreign-device", 10, {
linkedProjectDeviceId: "device-foreign",
}),
],
}),
createCircuitSnapshot(fixture, {
id: "foreign-room",
deviceRows: [
createRow("foreign-room", "row-foreign-room", 10, {
roomId: "room-foreign",
}),
],
}),
createCircuitSnapshot(fixture, {
id: "legacy-row",
deviceRows: [
createRow("legacy-row", "row-legacy", 10, {
legacyConsumerId: "legacy-user-value",
}),
],
}),
];
for (const snapshot of invalidSnapshots) {
assert.throws(() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(snapshot),
})
);
}
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
});
+521
View File
@@ -0,0 +1,521 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
deserializeProjectCommand,
serializeProjectCommand,
type SerializedProjectCommand,
} from "../src/domain/models/project-command.model.js";
import {
assertCircuitUpdateProjectCommand,
createCircuitUpdateProjectCommand,
} from "../src/domain/models/circuit-project-command.model.js";
import {
assertCircuitDeviceRowUpdateProjectCommand,
createCircuitDeviceRowUpdateProjectCommand,
} from "../src/domain/models/circuit-device-row-project-command.model.js";
import {
assertCircuitDeviceRowInsertProjectCommand,
createCircuitDeviceRowDeleteProjectCommand,
createCircuitDeviceRowInsertProjectCommand,
} from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import {
assertCircuitDeviceRowMoveProjectCommand,
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand,
createCircuitDeviceRowMoveProjectCommand,
createCircuitDeviceRowMoveWithNewCircuitProjectCommand,
} from "../src/domain/models/circuit-device-row-move-project-command.model.js";
import {
assertCircuitInsertProjectCommand,
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
} from "../src/domain/models/circuit-structure-project-command.model.js";
describe("serialized project commands", () => {
it("round-trips a versioned command envelope", () => {
const command: SerializedProjectCommand = {
schemaVersion: 1,
type: "circuit.update",
payload: {
circuitId: "circuit-1",
changes: {
displayName: "Werkstatt",
protectionRatedCurrent: 16,
isReserve: false,
remark: null,
},
},
};
assert.deepEqual(
deserializeProjectCommand(serializeProjectCommand(command)),
command
);
});
it("rejects values that JSON would lose or silently change", () => {
const invalidPayloads: unknown[] = [
{ value: undefined },
{ value: Number.NaN },
{ value: Number.POSITIVE_INFINITY },
{ value: new Date() },
];
for (const payload of invalidPayloads) {
assert.throws(() =>
serializeProjectCommand({
schemaVersion: 1,
type: "test.invalid",
payload: payload as never,
})
);
}
});
it("rejects cyclic payloads before persistence", () => {
const payload: Record<string, unknown> = {};
payload.self = payload;
assert.throws(
() =>
serializeProjectCommand({
schemaVersion: 1,
type: "test.cyclic",
payload: payload as never,
}),
/must not contain cycles/
);
});
it("rejects malformed serialized envelopes", () => {
assert.throws(
() =>
deserializeProjectCommand(
JSON.stringify({ schemaVersion: 0, type: "", payload: null })
),
/schema version/
);
});
});
describe("circuit update project commands", () => {
it("creates a typed command with explicit null clearing semantics", () => {
const command = createCircuitUpdateProjectCommand("circuit-1", {
displayName: null,
cableLength: 12.5,
isReserve: false,
});
assert.doesNotThrow(() => assertCircuitUpdateProjectCommand(command));
assert.deepEqual(command.payload.changes, [
{ field: "displayName", value: null },
{ field: "cableLength", value: 12.5 },
{ field: "isReserve", value: false },
]);
});
it("rejects empty, duplicate and invalid field changes", () => {
assert.throws(
() => createCircuitUpdateProjectCommand("circuit-1", {}),
/at least one change/
);
assert.throws(
() =>
assertCircuitUpdateProjectCommand({
schemaVersion: 1,
type: "circuit.update",
payload: {
circuitId: "circuit-1",
changes: [
{ field: "displayName", value: "A" },
{ field: "displayName", value: "B" },
],
},
}),
/duplicate field/
);
assert.throws(
() =>
createCircuitUpdateProjectCommand("circuit-1", {
voltage: 0,
}),
/outside its allowed range/
);
});
});
describe("circuit device-row update project commands", () => {
it("creates typed nullable device-row changes", () => {
const command = createCircuitDeviceRowUpdateProjectCommand("row-1", {
roomId: null,
quantity: 2,
cosPhi: 0.9,
});
assert.doesNotThrow(() =>
assertCircuitDeviceRowUpdateProjectCommand(command)
);
assert.deepEqual(command.payload.changes, [
{ field: "roomId", value: null },
{ field: "quantity", value: 2 },
{ field: "cosPhi", value: 0.9 },
]);
});
it("rejects invalid device-row values", () => {
assert.throws(
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {}),
/at least one change/
);
assert.throws(
() =>
createCircuitDeviceRowUpdateProjectCommand("row-1", {
displayName: "",
}),
/non-empty string/
);
assert.throws(
() =>
createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: -1,
}),
/non-negative/
);
});
});
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/
);
});
});
describe("circuit device-row move project commands", () => {
const targetCircuit = {
id: "circuit-new",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F3",
displayName: "Neuer Stromkreis",
sortOrder: 30,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: null,
controlRequirement: null,
status: null,
isReserve: true,
remark: null,
deviceRows: [],
};
it("captures deterministic source and target positions", () => {
const command = createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: "circuit-2",
targetSortOrder: 30,
},
{
rowId: "row-2",
expectedCircuitId: "circuit-1",
expectedSortOrder: 20,
targetCircuitId: "circuit-2",
targetSortOrder: 40,
},
]);
assert.equal(command.payload.moves.length, 2);
assert.doesNotThrow(() =>
assertCircuitDeviceRowMoveProjectCommand(command)
);
});
it("rejects empty, duplicate and no-op moves", () => {
assert.throws(
() => createCircuitDeviceRowMoveProjectCommand([]),
/at least one move/
);
assert.throws(
() =>
createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: "circuit-2",
targetSortOrder: 20,
},
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 30,
targetCircuitId: "circuit-2",
targetSortOrder: 40,
},
]),
/duplicate row ids/
);
assert.throws(
() =>
createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: "circuit-1",
targetSortOrder: 10,
},
]),
/no-op/
);
});
it("captures creation and deletion of a deterministic move target", () => {
const create =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
targetCircuit,
[
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: targetCircuit.id,
targetSortOrder: 10,
},
]
);
const remove =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"delete",
targetCircuit,
[
{
rowId: "row-1",
expectedCircuitId: targetCircuit.id,
expectedSortOrder: 10,
targetCircuitId: "circuit-1",
targetSortOrder: 10,
},
]
);
assert.doesNotThrow(() =>
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
create
)
);
assert.equal(remove.payload.targetCircuitAction, "delete");
});
it("rejects inconsistent move targets and non-empty snapshots", () => {
assert.throws(
() =>
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
targetCircuit,
[
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: "other-circuit",
targetSortOrder: 10,
},
]
),
/move rows into the new circuit/
);
assert.throws(
() =>
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand({
schemaVersion: 1,
type: "circuit-device-row.move-with-new-circuit",
payload: {
targetCircuitAction: "create",
targetCircuit: {
...targetCircuit,
isReserve: false,
},
moves: [
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: targetCircuit.id,
targetSortOrder: 10,
},
],
},
}),
/reserve state/
);
});
});
describe("circuit 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: null,
remark: null,
overriddenFields: null,
};
const circuit = {
id: "circuit-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F1",
displayName: "Beleuchtung",
sortOrder: 10,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: "DALI",
status: null,
isReserve: false,
remark: null,
deviceRows: [row],
};
it("captures complete circuit blocks and delete identities", () => {
const insert = createCircuitInsertProjectCommand(circuit);
const remove = createCircuitDeleteProjectCommand(
circuit.id,
circuit.circuitListId
);
assert.deepEqual(insert.payload.circuit, circuit);
assert.deepEqual(remove.payload, {
circuitId: "circuit-1",
expectedCircuitListId: "list-1",
});
});
it("rejects inconsistent reserve state, row ownership and duplicate ids", () => {
assert.throws(
() =>
createCircuitInsertProjectCommand({
...circuit,
isReserve: true,
}),
/reserve state/
);
assert.throws(
() =>
createCircuitInsertProjectCommand({
...circuit,
deviceRows: [{ ...row, circuitId: "other-circuit" }],
}),
/different circuit/
);
assert.throws(
() =>
createCircuitInsertProjectCommand({
...circuit,
deviceRows: [row, { ...row }],
}),
/duplicate row ids/
);
assert.throws(
() =>
assertCircuitInsertProjectCommand({
schemaVersion: 1,
type: "circuit.insert",
payload: {
circuit: { ...circuit, equipmentIdentifier: "" },
},
}),
/equipmentIdentifier/
);
});
});
+544
View File
@@ -0,0 +1,544 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-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 { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-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 { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
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 {
createCircuitDeviceRowMoveProjectCommand,
createCircuitDeviceRowMoveWithNewCircuitProjectCommand,
} from "../src/domain/models/circuit-device-row-move-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 { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.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" })
.run();
const board = new DistributionBoardRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
assert.ok(section);
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Bestand",
sortOrder: 10,
})
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})
.run();
return context;
}
function createService(context: DatabaseContext) {
return new ProjectCommandService(
new CircuitProjectCommandRepository(context.db),
new CircuitDeviceRowProjectCommandRepository(context.db),
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db)
);
}
function getCircuitName(context: DatabaseContext) {
return context.db
.select({ displayName: circuits.displayName })
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get()?.displayName;
}
function getRowQuantity(context: DatabaseContext) {
return context.db
.select({ quantity: circuitDeviceRows.quantity })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.quantity;
}
describe("project command service", () => {
it("dispatches supported commands and persists undo/redo across service instances", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const circuitResult = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Neu",
}),
});
assert.equal(circuitResult.revision.revisionNumber, 1);
assert.equal(circuitResult.history.undoDepth, 1);
assert.equal(getCircuitName(context), "Neu");
const rowResult = service.executeUser({
projectId: "project-1",
expectedRevision: 1,
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
}),
});
assert.equal(rowResult.revision.revisionNumber, 2);
assert.equal(rowResult.history.undoDepth, 2);
assert.equal(getRowQuantity(context), 2);
const reloadedService = createService(context);
const firstUndo = reloadedService.undo({
projectId: "project-1",
expectedRevision: 2,
});
assert.equal(firstUndo.revision.revisionNumber, 3);
assert.equal(firstUndo.history.undoDepth, 1);
assert.equal(firstUndo.history.redoDepth, 1);
assert.equal(getRowQuantity(context), 1);
const secondUndo = reloadedService.undo({
projectId: "project-1",
expectedRevision: 3,
});
assert.equal(secondUndo.revision.revisionNumber, 4);
assert.equal(secondUndo.history.undoDepth, 0);
assert.equal(secondUndo.history.redoDepth, 2);
assert.equal(getCircuitName(context), "Bestand");
const redo = reloadedService.redo({
projectId: "project-1",
expectedRevision: 4,
});
assert.equal(redo.revision.revisionNumber, 5);
assert.equal(redo.history.undoDepth, 1);
assert.equal(redo.history.redoDepth, 1);
assert.equal(getCircuitName(context), "Neu");
assert.deepEqual(
context.db
.select({ source: projectRevisions.source })
.from(projectRevisions)
.all()
.map((row) => row.source),
["user", "user", "undo", "undo", "redo"]
);
} finally {
context.close();
}
});
it("rejects stale undo and preserves the domain value and stack", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const changed = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Neu",
}),
});
assert.throws(
() =>
service.undo({
projectId: "project-1",
expectedRevision: 0,
}),
(error) =>
error instanceof ProjectRevisionConflictError &&
error.actualRevision === 1
);
assert.equal(getCircuitName(context), "Neu");
assert.deepEqual(
new ProjectHistoryRepository(context.db).getState("project-1"),
changed.history
);
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
assert.equal(context.db.select().from(projectChangeSets).all().length, 1);
} finally {
context.close();
}
});
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("dispatches complete circuit insertion and its persisted inverse", () => {
const context = createTestDatabase();
try {
const existingCircuit = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(existingCircuit);
const service = createService(context);
const inserted = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitInsertProjectCommand({
id: "circuit-new",
circuitListId: existingCircuit.circuitListId,
sectionId: existingCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Reserve",
sortOrder: 20,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: null,
status: null,
isReserve: true,
remark: null,
deviceRows: [],
}),
});
assert.equal(inserted.history.undoDepth, 1);
assert.equal(
context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-new"))
.get()?.equipmentIdentifier,
"-1F2"
);
const undone = createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(undone.history.redoDepth, 1);
assert.equal(
context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-new"))
.get(),
undefined
);
} finally {
context.close();
}
});
it("dispatches deterministic device-row moves and their inverse", () => {
const context = createTestDatabase();
try {
const sourceCircuit = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(sourceCircuit);
context.db
.insert(circuits)
.values({
id: "circuit-2",
circuitListId: sourceCircuit.circuitListId,
sectionId: sourceCircuit.sectionId,
equipmentIdentifier: "-1F2",
sortOrder: 20,
isReserve: 1,
})
.run();
const service = createService(context);
const moved = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: "circuit-2",
targetSortOrder: 10,
},
]),
});
assert.equal(moved.history.undoDepth, 1);
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.circuitId,
"circuit-2"
);
createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.circuitId,
"circuit-1"
);
} finally {
context.close();
}
});
it("dispatches placeholder moves that create and remove a circuit", () => {
const context = createTestDatabase();
try {
const sourceCircuit = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(sourceCircuit);
const targetCircuit = {
id: "circuit-placeholder-target",
circuitListId: sourceCircuit.circuitListId,
sectionId: sourceCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Neuer Stromkreis",
sortOrder: 20,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: null,
controlRequirement: null,
status: null,
isReserve: true,
remark: null,
deviceRows: [],
};
const moved = createService(context).executeUser({
projectId: "project-1",
expectedRevision: 0,
command:
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
targetCircuit,
[
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: targetCircuit.id,
targetSortOrder: 10,
},
]
),
});
assert.equal(moved.history.undoDepth, 1);
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.circuitId,
targetCircuit.id
);
createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(
context.db
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, targetCircuit.id))
.get(),
undefined
);
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.circuitId,
"circuit-1"
);
} finally {
context.close();
}
});
it("rejects unavailable history directions without writing a revision", () => {
const context = createTestDatabase();
try {
const service = createService(context);
assert.throws(
() =>
service.undo({
projectId: "project-1",
expectedRevision: 0,
}),
(error) =>
error instanceof ProjectHistoryOperationUnavailableError &&
error.direction === "undo"
);
assert.throws(
() =>
service.redo({
projectId: "project-1",
expectedRevision: 0,
}),
(error) =>
error instanceof ProjectHistoryOperationUnavailableError &&
error.direction === "redo"
);
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
} finally {
context.close();
}
});
it("rejects unsupported and malformed commands before domain writes", () => {
const context = createTestDatabase();
try {
const service = createService(context);
assert.throws(
() =>
service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: {
schemaVersion: 1,
type: "unknown.command",
payload: {},
},
}),
/Unsupported project command type/
);
assert.throws(
() =>
service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: {
schemaVersion: 1,
type: "circuit.update",
payload: { circuitId: "circuit-1", changes: [] },
},
}),
/at least one change/
);
assert.equal(getCircuitName(context), "Bestand");
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
} finally {
context.close();
}
});
});
+275
View File
@@ -0,0 +1,275 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
import { 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";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectHistoryStackEntries } from "../src/db/schema/project-history-stack-entries.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-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" })
.run();
const board = new DistributionBoardRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
assert.ok(section);
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Bestand",
sortOrder: 10,
})
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})
.run();
return context;
}
function getCircuitName(context: DatabaseContext) {
return context.db
.select({ displayName: circuits.displayName })
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get()?.displayName;
}
function getRowQuantity(context: DatabaseContext) {
return context.db
.select({ quantity: circuitDeviceRows.quantity })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.quantity;
}
describe("project history repository", () => {
it("persists project-wide undo and redo stacks across repository instances", () => {
const context = createTestDatabase();
try {
const circuitsStore = new CircuitProjectCommandRepository(context.db);
const rowsStore = new CircuitDeviceRowProjectCommandRepository(context.db);
const circuitCommand = createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Neu",
});
const circuitUpdate = circuitsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: circuitCommand,
});
const rowCommand = createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
});
const rowUpdate = rowsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: rowCommand,
});
const reloadedHistory = new ProjectHistoryRepository(context.db);
assert.deepEqual(reloadedHistory.getState("project-1"), {
projectId: "project-1",
currentRevision: 2,
undoDepth: 2,
redoDepth: 0,
undoChangeSetId: rowUpdate.revision.changeSetId,
redoChangeSetId: null,
});
assert.throws(
() =>
circuitsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
historyTargetChangeSetId: circuitUpdate.revision.changeSetId,
command: circuitUpdate.inverse,
}),
/not the top undo command/
);
assert.equal(getCircuitName(context), "Neu");
assert.equal(
context.db
.select()
.from(projects)
.where(eq(projects.id, "project-1"))
.get()?.currentRevision,
2
);
rowsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
historyTargetChangeSetId: rowUpdate.revision.changeSetId,
command: rowUpdate.inverse,
});
assert.equal(getRowQuantity(context), 1);
assert.deepEqual(reloadedHistory.getState("project-1"), {
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 1,
undoChangeSetId: circuitUpdate.revision.changeSetId,
redoChangeSetId: rowUpdate.revision.changeSetId,
});
circuitsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 3,
source: "undo",
historyTargetChangeSetId: circuitUpdate.revision.changeSetId,
command: circuitUpdate.inverse,
});
assert.equal(getCircuitName(context), "Bestand");
assert.deepEqual(reloadedHistory.getState("project-1"), {
projectId: "project-1",
currentRevision: 4,
undoDepth: 0,
redoDepth: 2,
undoChangeSetId: null,
redoChangeSetId: circuitUpdate.revision.changeSetId,
});
circuitsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 4,
source: "redo",
historyTargetChangeSetId: circuitUpdate.revision.changeSetId,
command: circuitCommand,
});
assert.equal(getCircuitName(context), "Neu");
assert.deepEqual(reloadedHistory.getState("project-1"), {
projectId: "project-1",
currentRevision: 5,
undoDepth: 1,
redoDepth: 1,
undoChangeSetId: circuitUpdate.revision.changeSetId,
redoChangeSetId: rowUpdate.revision.changeSetId,
});
const branchedRowUpdate = rowsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 5,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 3,
}),
});
assert.equal(getRowQuantity(context), 3);
assert.deepEqual(reloadedHistory.getState("project-1"), {
projectId: "project-1",
currentRevision: 6,
undoDepth: 2,
redoDepth: 0,
undoChangeSetId: branchedRowUpdate.revision.changeSetId,
redoChangeSetId: null,
});
assert.equal(context.db.select().from(projectRevisions).all().length, 6);
assert.equal(context.db.select().from(projectChangeSets).all().length, 6);
assert.equal(
context.db.select().from(projectHistoryStackEntries).all().length,
2
);
} finally {
context.close();
}
});
it("rolls back domain and revision writes when stack persistence fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_history_stack_insert
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced history stack failure');
END;
`);
const store = new CircuitProjectCommandRepository(context.db);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Darf nicht bleiben",
}),
}),
/forced history stack failure/
);
assert.equal(getCircuitName(context), "Bestand");
assert.deepEqual(
new ProjectHistoryRepository(context.db).getState("project-1"),
{
projectId: "project-1",
currentRevision: 0,
undoDepth: 0,
redoDepth: 0,
undoChangeSetId: null,
redoChangeSetId: null,
}
);
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
} finally {
context.close();
}
});
it("returns null for an unknown project", () => {
const context = createTestDatabase();
try {
assert.equal(
new ProjectHistoryRepository(context.db).getState("missing"),
null
);
} finally {
context.close();
}
});
});
+160
View File
@@ -0,0 +1,160 @@
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 {
ProjectRevisionConflictError,
ProjectRevisionRepository,
} from "../src/db/repositories/project-revision.repository.js";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.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" }).run();
return context;
}
function appendTestRevision(
repository: ProjectRevisionRepository,
expectedRevision: number
) {
return repository.append({
projectId: "project-1",
expectedRevision,
source: "user",
description: "Stromkreis bearbeiten",
actorId: "test-user",
forward: {
schemaVersion: 1,
type: "circuit.update",
payload: { circuitId: "circuit-1", displayName: "Neu" },
},
inverse: {
schemaVersion: 1,
type: "circuit.update",
payload: { circuitId: "circuit-1", displayName: "Alt" },
},
});
}
describe("project revision repository", () => {
it("appends immutable revision metadata and payloads with a monotonic number", () => {
const context = createTestDatabase();
try {
const repository = new ProjectRevisionRepository(context.db);
assert.equal(repository.getCurrentRevision("project-1"), 0);
const appended = appendTestRevision(repository, 0);
assert.equal(appended.projectId, "project-1");
assert.equal(appended.revisionNumber, 1);
assert.equal(repository.getCurrentRevision("project-1"), 1);
const revision = context.db
.select()
.from(projectRevisions)
.where(eq(projectRevisions.id, appended.revisionId))
.get();
const changeSet = context.db
.select()
.from(projectChangeSets)
.where(eq(projectChangeSets.id, appended.changeSetId))
.get();
assert.deepEqual(revision, {
id: appended.revisionId,
projectId: "project-1",
revisionNumber: 1,
createdAtIso: appended.createdAtIso,
actorId: "test-user",
source: "user",
description: "Stromkreis bearbeiten",
});
assert.deepEqual(changeSet, {
id: appended.changeSetId,
projectRevisionId: appended.revisionId,
commandType: "circuit.update",
payloadSchemaVersion: 1,
forwardPayloadJson: JSON.stringify({
schemaVersion: 1,
type: "circuit.update",
payload: {
circuitId: "circuit-1",
displayName: "Neu",
},
}),
inversePayloadJson: JSON.stringify({
schemaVersion: 1,
type: "circuit.update",
payload: {
circuitId: "circuit-1",
displayName: "Alt",
},
}),
});
const next = appendTestRevision(repository, 1);
assert.equal(next.revisionNumber, 2);
assert.equal(repository.getCurrentRevision("project-1"), 2);
} finally {
context.close();
}
});
it("rejects a stale expected revision without writing history", () => {
const context = createTestDatabase();
try {
const repository = new ProjectRevisionRepository(context.db);
appendTestRevision(repository, 0);
assert.throws(
() => appendTestRevision(repository, 0),
(error) =>
error instanceof ProjectRevisionConflictError &&
error.expectedRevision === 0 &&
error.actualRevision === 1
);
assert.equal(repository.getCurrentRevision("project-1"), 1);
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
assert.equal(context.db.select().from(projectChangeSets).all().length, 1);
} finally {
context.close();
}
});
it("rolls back the project counter and revision when change-set storage fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_project_change_set_insert
BEFORE INSERT ON project_change_sets
BEGIN
SELECT RAISE(ABORT, 'forced change-set failure');
END;
`);
const repository = new ProjectRevisionRepository(context.db);
assert.throws(
() => appendTestRevision(repository, 0),
/forced change-set failure/
);
assert.equal(repository.getCurrentRevision("project-1"), 0);
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
} finally {
context.close();
}
});
});