Compare commits

...

9 Commits

Author SHA1 Message Date
jappel 2eba4ea75e Persist circuit structure edits 2026-07-25 20:49:34 +02:00
jappel 76d8d59391 Persist circuit cell edits 2026-07-25 20:26:45 +02:00
jappel 3977a6e6e1 Persist project device synchronization 2026-07-24 11:47:03 +02:00
jappel e930cb75b8 Route project devices through history 2026-07-24 10:57:50 +02:00
jappel ac16cca77a Add project device structure history 2026-07-24 09:09:59 +02:00
jappel 0bc6c7372f Add project device update history 2026-07-24 08:57:57 +02:00
jappel 2668fc2f16 Add project device sync history 2026-07-24 08:47:25 +02:00
jappel 4b4603b71b Add section renumber history 2026-07-24 08:34:31 +02:00
jappel 332dfdb5d9 Add circuit reorder history 2026-07-24 08:21:32 +02:00
64 changed files with 6778 additions and 2070 deletions
+26 -9
View File
@@ -220,15 +220,32 @@ After saving, the row becomes linked to the new project device.
## Undo / Redo
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.
The editor's visible undo/redo stack remains session-local while move, reorder
and renumber operations are being migrated. Existing Circuit and
CircuitDeviceRow cell edits plus standalone insertion/deletion already execute
persistent project commands; their toolbar undo/redo actions use the
project-wide history endpoints. Insertions use client-generated stable UUIDs,
and deletion undo restores the same ids from complete server snapshots. The
tree response supplies the optimistic `currentRevision`; obsolete direct field
PATCH, structure POST and CircuitDeviceRow DELETE routes are removed.
Reloading the page does not yet reconstruct the visible editor stack.
CircuitDeviceRow moves between existing circuits and moves that create one new
placeholder target circuit are persisted. The latter stores the complete empty
target snapshot so undo can restore the rows and remove only the unchanged
generated circuit. Complete
in-section Circuit reorders are persisted separately and change sort positions
without changing equipment identifiers. Explicit complete-section renumbering
is persisted through a separate collision-safe command and is never triggered
by sorting or moving. Project-device synchronization, disconnect and reconnect
are persisted as one atomic multi-row command with complete expected/target row
snapshots, including link and override metadata. Canonical ProjectDevice field
updates are also persisted and never synchronize linked rows implicitly.
ProjectDevice insertion/deletion preserves stable device ids. Deletion captures
complete disconnected snapshots of linked rows so undo can restore only rows
that have remained unchanged. ProjectDevice create, update, delete and
global-to-project copy API/UI paths use these persistent commands and track the
returned project revision. ProjectDevice synchronization/disconnect API and UI
paths do the same; their undo action uses the project-wide history endpoint.
Required operations:
+85 -45
View File
@@ -5,9 +5,9 @@
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 responses from `GET /projects`, `GET /projects/:projectId` and the
circuit-tree endpoint include `currentRevision`. Versioned project commands use
this value for optimistic concurrency checks.
### Project Commands and History
@@ -25,16 +25,20 @@ concurrency checks.
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.insert`, `circuit-device-row.delete`,
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
`circuit.reorder-section`, `circuit.renumber-section` and
`project-device.update`, `project-device.insert`, `project-device.delete` and
`project-device.sync-rows`, all with schema version `1`. Other command types
are rejected. Insert commands contain the complete entity or circuit block
with stable ids; delete commands include the expected parent identity. Circuit
snapshots contain zero, one or multiple complete device rows. Move commands
contain each row's expected and target circuit plus its exact expected and
target sort order. This makes deletion and moves undoable without generating
replacement identities or renumbering circuits. Existing Circuit and
CircuitDeviceRow cell edits plus standalone insert/delete actions in the editor
consume these endpoints. Move, reorder and renumber UI paths are still being
migrated.
`circuit-device-row.move` targets existing circuits in the same circuit list.
`circuit-device-row.move-with-new-circuit` atomically creates exactly one
@@ -43,6 +47,58 @@ 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.
`circuit.reorder-section` requires one assignment for every circuit currently
in the section. Each assignment records the expected and target `sortOrder`.
The command and its inverse change no circuit field other than `sortOrder`;
equipment identifiers and complete device-row blocks remain unchanged.
`circuit.renumber-section` is an explicit operation requiring one assignment
for every circuit in the section. Assignments contain expected and target
equipment identifiers. The store rejects stale values, duplicate targets and
targets occupied by other sections, then applies swaps through collision-safe
temporary identifiers. Undo restores the exact prior identifiers; sort
positions and device rows remain unchanged.
`project-device.sync-rows` represents `synchronize`, `disconnect` and
`reconnect` operations. Every selected row carries complete expected and target
snapshots of all ProjectDevice-sync fields, the link and `overriddenFields`.
All rows, the inverse command, revision and history-stack transition commit or
roll back together. Disconnect/reconnect may only change the link; stale row
snapshots and cross-project devices or rows are rejected.
`project-device.update` changes one or multiple canonical ProjectDevice fields
and derives its inverse from the persisted device. It validates project
ownership and never writes linked `CircuitDeviceRow` values; synchronization
remains a separate explicit command.
`project-device.insert` stores the complete canonical device with a stable id.
User inserts cannot attach existing rows. `project-device.delete` captures the
complete device and every currently linked row before deletion. Its inverse
recreates the same device and reconnects only rows whose complete disconnected
snapshot still matches; device, links, revision and history transition are
atomic.
### Project Device CRUD
- `GET /project-devices/projects/:projectId`
- lists the project's reusable devices
- `POST /project-devices/projects/:projectId`
- `PUT /project-devices/projects/:projectId/:projectDeviceId`
- request: all canonical device fields plus `expectedRevision`
- response: `{ "projectDevice": { ... }, "revision": { ... }, "history": { ... } }`
- `DELETE /project-devices/projects/:projectId/:projectDeviceId`
- request: `{ "expectedRevision": 12 }`
- response: the project command result with the updated history state
- `POST /project-devices/projects/:projectId/import-global/:globalDeviceId`
- request: `{ "expectedRevision": 12 }`
- creates an independent project device with a stable UUID
- response shape matches ProjectDevice create/update
These write endpoints execute the typed `project-device.update`,
`project-device.insert` and `project-device.delete` commands. A stale revision
returns `409 PROJECT_REVISION_CONFLICT`. Updating a project device still never
synchronizes linked circuit rows implicitly.
Example:
```json
@@ -83,6 +139,7 @@ Response sketch:
```json
{
"circuitListId": "cl_1",
"currentRevision": 12,
"sections": [
{
"id": "sec_1",
@@ -107,38 +164,17 @@ Response sketch:
}
```
### Circuit CRUD
### Circuit Structure
- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits`
- create circuit in list/section
- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows`
- atomically create one circuit and one or more initial device rows
- `PATCH /circuits/:circuitId`
- update circuit-level fields (BMK, protection, cable, reserve flag, etc.)
- `DELETE /circuits/:circuitId`
- delete circuit (and related rows via DB relations)
- `GET /circuit-sections/:sectionId/next-identifier`
- preview next identifier for section (`prefix + maxSuffix + 1`)
Request sketch (`POST .../circuits`):
```json
{
"sectionId": "sec_1",
"equipmentIdentifier": "-2F14",
"displayName": "Sockets East",
"sortOrder": 140
}
```
### Device Row CRUD
- `POST /circuits/:circuitId/device-rows`
- create row inside a circuit
- `PATCH /circuit-device-rows/:rowId`
- update row values
- `DELETE /circuit-device-rows/:rowId`
- delete row
Circuit and device-row field updates, standalone insertions and standalone
deletions are available only as the corresponding versioned commands through
`POST /projects/:projectId/commands`. There are no direct field-update PATCH,
structure POST or CircuitDeviceRow DELETE routes. The direct
`DELETE /circuits/:circuitId` route remains temporarily for the inverse of the
not-yet-migrated placeholder move UI and must not be used by new features.
### Move Device Row
@@ -223,12 +259,16 @@ endpoints above.
- returns all linked circuit device rows with distribution-board/circuit context and field differences
- `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize`
- applies only explicitly selected fields to explicitly selected linked rows
- `POST /project-devices/projects/:projectId/:projectDeviceId/restore`
- restores the captured pre-sync values for session-local undo
- request: selected `rowIds`, selected `fields` and `expectedRevision`
- response: updated preview, revision and project history state
- `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect`
- disconnects explicitly selected rows without changing their local values
- `POST /project-devices/projects/:projectId/:projectDeviceId/reconnect`
- restores a disconnected link if the row has not been linked elsewhere meanwhile
- request: selected `rowIds` and `expectedRevision`
- response: updated preview, revision and project history state
Both writes execute `project-device.sync-rows`. Undo uses the project-wide
`POST /projects/:projectId/history/undo` endpoint; reconnect is the validated
inverse command. The former direct restore/reconnect endpoints are removed.
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
project device never triggers synchronization implicitly.
+14 -4
View File
@@ -11,7 +11,10 @@ Inline cells are static text by default. A cell enters edit mode by:
`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell.
Committed cell edits are persisted as partial database updates. Fields that were not part of the edit are not written back from an older read and therefore cannot overwrite a concurrent change to another field.
Committed cells on existing circuits and device rows are persisted as typed
project commands with an optimistic project revision. Only the edited fields
are included, so an older tree response cannot write unrelated fields back.
The server records the inverse in the same transaction.
## `selectedCell` vs `editingCell`
@@ -119,7 +122,13 @@ Cross-section device moves show confirmation-required feedback before drop. The
## Undo/Redo
Undo/redo wraps editor operations as command objects with async `redo`/`undo` and reloads tree after each command.
Undo/redo wraps editor operations as command objects and reloads the tree after
each command. Existing Circuit and device-row cell edits plus standalone
insert/delete actions execute persistent project commands; their toolbar
undo/redo calls the project-wide server history. New entities receive stable
UUIDs before insertion, and deletion undo restores those same ids. Move,
reorder and renumber operations still use their existing direct API writes and
session-local inverse callbacks during the cutover.
Covered operations include:
@@ -132,5 +141,6 @@ Covered operations include:
Current limitations:
- session-local only
- no persisted history across browser reload
- the visible editor stack is still session-local and is empty after reload
- move, reorder and renumber editor actions are not connected to the persistent
command boundary yet
@@ -1,8 +1,14 @@
# Circuit List Editor Known Limitations
- Undo/redo history is session-local only.
- Undo/redo history is not persisted across reloads or between users.
- No project revision or logical snapshot model is implemented yet.
- Existing Circuit and CircuitDeviceRow cell edits plus standalone
insert/delete actions use persistent project commands and project-wide
toolbar undo/redo. The editor's visible stack is still session-local, is
empty after reload and still contains direct move/reorder/renumber operations
during the cutover.
- Server-side project revisions and persistent undo/redo eligibility exist for
the command types documented in `circuit-list-editor-api.md`; complete editor
command coverage and history reconstruction are not complete.
- Named logical project snapshots and restore are not implemented yet.
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
- The global device library supports basic CRUD and copy operations, but has no
+47 -8
View File
@@ -41,7 +41,7 @@ liegt über einen Host-Mount außerhalb des Containers.
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
unterstützte Editorroute
- `src/frontend/components/circuit-tree-editor.tsx` Editorzustand,
Befehlsausführung, Drag-and-drop und sitzungslokales Undo/Redo
Befehlsausführung, Drag-and-drop und der schrittweise Historien-Cutover
- `src/frontend/components/circuit-grid-*.ts` reine Grid-Projektion,
Zellbesitz, Einfügen und Sicherheitsregeln
- `src/frontend/utils/api.ts` typisierte Frontend-API-Aufrufe
@@ -62,7 +62,8 @@ 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. Das Datenmodell besitzt bereits
Der sichtbare React-Historiestapel ist während des schrittweisen Cutovers noch
sitzungslokal. Das Datenmodell besitzt
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
@@ -85,8 +86,15 @@ 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.
Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen
sowie eigenständiges Einfügen und Löschen verwendet das Grid bereits über die
öffentliche Command-Grenze. Neue Circuits und Gerätezeilen erhalten ihre stabile
UUID vor dem Command; Löschen und Undo bewahren diese Identität. Der Tree liefert
dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite
Serverhistorie. Direkte Feld-PATCH-, Struktur-POST- und Gerätezeilen-DELETE-
Endpunkte sind entfernt. Move-, Reorder- und Renumber-UI-Pfade verbleiben
vorerst im sichtbaren sitzungslokalen Stack; nach einem Reload wird dieser noch
nicht aus der Serverhistorie rekonstruiert.
`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;
@@ -95,9 +103,39 @@ 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.
Felder und vollständiger Zeilenbestand unverändert sind.
`circuit.reorder-section` speichert die erwartete und neue Sortierposition
jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs
bleiben unverändert. `circuit.renumber-section` bildet die getrennte,
ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und
neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und
ändert weder Sortierung noch Gerätezeilen.
`project-device.sync-rows` persistiert Synchronisierung, Trennen und erneutes
Verknüpfen als atomaren Mehrzeilen-Command. Jede betroffene Zeile enthält den
vollständigen erwarteten und neuen Stand aller synchronisierbaren Felder,
einschließlich ProjectDevice-Verknüpfung und lokaler Override-Metadaten. Damit
werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stellt
exakt die vorherigen lokalen Werte wieder her. Die Synchronisieren- und
Trennen-Endpunkte sowie die Projektseite verwenden diesen Command mit
optimistischer Revisionsprüfung. Das dortige Rückgängig löst die persistente
projektweite Historie aus; separate direkte Restore-/Reconnect-Schreibwege
existieren nicht mehr.
`project-device.update` versioniert Änderungen an den kanonischen
Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem
gespeicherten Gerät und schreibt Geräteänderung, Revision und Historienstapel
atomar. Verknüpfte Stromkreiszeilen werden dabei bewusst nicht automatisch
synchronisiert. Der ProjectDevice-`PUT`-Endpunkt und die Projektseite verwenden
diesen Command mit optimistischer Revisionsprüfung.
`project-device.insert` und `project-device.delete` versionieren außerdem den
Lebenszyklus eines Projektgeräts mit stabiler UUID. Beim Löschen speichert das
inverse Insert den vollständigen Gerätestand sowie vollständige, nach dem
Löschen erwartete Snapshots aller zuvor verknüpften Gerätezeilen. Undo setzt
die Links nur zurück, wenn diese Zeilen weiterhin zum Projekt gehören,
unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revision
und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
## Projektgeräte
@@ -129,7 +167,8 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Noch nicht unterstützt
- persistentes Undo/Redo und Projektversionen
- vollständiges persistentes Undo/Redo, sichtbare Historie nach Reload und
benannte Projektstände
- Mehrbenutzerbetrieb und Konfliktauflösung
- Revit-/CSV-/IFCGUID-Round-trip
- vollständige elektrische Dimensionierung
@@ -173,12 +173,20 @@ Completed foundation:
to SQLite
- public command, undo and redo endpoints require an expected revision; undo
and redo reconstruct the eligible persisted inverse or forward command
- the circuit tree returns the project's current revision; existing Circuit
and CircuitDeviceRow cell edits use the public command endpoint, while their
toolbar undo/redo actions use project-wide persistent history
- obsolete direct Circuit and CircuitDeviceRow field-update PATCH routes are
removed so these editor writes cannot bypass revision history
- `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
- standalone Circuit/CircuitDeviceRow insert/delete UI paths use these commands
with client-generated stable UUIDs; obsolete direct structure POST and
CircuitDeviceRow DELETE routes are removed
- `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
@@ -186,18 +194,31 @@ Completed foundation:
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
- `circuit.reorder-section` requires the complete section circuit set and
stores exact expected/target sort positions; its inverse changes no
equipment identifier and never splits a circuit block
- `circuit.renumber-section` is the separate explicit renumber operation; it
stores every expected/target equipment identifier, uses collision-safe
temporary values and restores the exact prior identifiers on undo
- `project-device.sync-rows` records synchronization, disconnect and reconnect
as one atomic multi-row operation; complete expected/target sync snapshots
protect local values, links and override metadata against silent stale writes
- `project-device.update` persists canonical source-device field changes
independently and never synchronizes linked rows implicitly
- `project-device.insert` and `project-device.delete` preserve stable device
ids; delete undo restores links only from complete unchanged disconnected-row
snapshots in the same transaction
Remaining constraints before completing persistent history:
- extend the concrete project-scoped command union and executors beyond the
Circuit field/insert/delete and CircuitDeviceRow
field/insert/delete/move 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
- route the remaining move, reorder and renumber editor writes through their
existing typed persistent commands and remove the final direct mutation
routes
- reconstruct the visible frontend history state after reload; Circuit and
CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations
already use persistent commands and project-wide undo
- extend project-scoped commands only when further project mutations are
deliberately added to history; the generic versioned envelope is complete
- do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests
+28 -3
View File
@@ -390,26 +390,51 @@ Implemented foundation:
- a central application dispatcher reconstructs persisted commands and exposes
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
field updates
- the circuit tree exposes `currentRevision`; existing Circuit and
CircuitDeviceRow cell edits use those optimistic commands and project-wide
undo/redo instead of direct field-update PATCH routes
- 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
- standalone Circuit/CircuitDeviceRow insert/delete editor paths use those
commands with stable client-generated UUIDs and project-wide undo/redo; the
obsolete direct structure POST and CircuitDeviceRow DELETE routes are removed
- 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
- complete section reorders persist every expected and target circuit
`sortOrder`; undo/redo changes neither equipment identifiers nor device rows
- explicit section renumber commands persist every expected and target
equipment identifier, apply swaps collision-safely and restore exact prior
identifiers without changing sort positions or device rows
- ProjectDevice row synchronization, disconnect and reconnect persist complete
expected/target sync snapshots in one atomic multi-row command; undo restores
local values, links and override metadata without silent overwrites
- canonical ProjectDevice field updates persist independently with exact
inverses and never overwrite linked CircuitDeviceRow values implicitly
- ProjectDevice insert/delete commands preserve stable device ids and restore
previously linked rows only from complete unchanged disconnected snapshots
- ProjectDevice create, update, delete and global-to-project copy endpoints use
these commands, require `expectedRevision` and return the new history state
- ProjectDevice synchronization and disconnect endpoints also require
`expectedRevision`; their UI undo uses project-wide persistent history and
the obsolete direct restore/reconnect write paths are removed
- 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.
ProjectDevice CRUD, synchronization and disconnect on the project page and
Circuit/CircuitDeviceRow field/insert/delete actions in the circuit-list editor
are connected to this history boundary. Move, reorder and renumber UI paths are
not yet connected; the editor's visible undo/redo stack therefore remains
session-local and is not reconstructed after reload.
Acceptance criteria:
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-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",
"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-structure-command.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-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.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-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts",
+94 -40
View File
@@ -20,9 +20,8 @@ import {
listProjectDevices,
listProjects,
listRooms,
reconnectProjectDeviceRows,
restoreProjectDeviceRows,
synchronizeProjectDeviceRows,
undoProjectCommand,
updateProjectDevice,
updateProjectSettings,
} from "../../../frontend/utils/api";
@@ -34,7 +33,6 @@ import type {
GlobalDeviceDto,
ProjectDeviceDto,
ProjectDeviceSyncPreviewDto,
ProjectDeviceSyncRestoreRowDto,
ProjectDto,
RoomDto,
} from "../../../frontend/types";
@@ -57,9 +55,10 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
remark: "Bemerkung",
};
type ProjectDeviceUndoState =
| { kind: "synchronize"; projectDeviceId: string; rows: ProjectDeviceSyncRestoreRowDto[] }
| { kind: "disconnect"; projectDeviceId: string; rowIds: string[] };
type ProjectDeviceUndoState = {
projectDeviceId: string;
revisionNumber: number;
};
const emptyProjectDevice: CreateProjectDeviceInput = {
name: "",
@@ -176,6 +175,13 @@ export default function ProjectDetailPage() {
[circuitLists]
);
function applyProjectRevision(currentRevision: number) {
setProject((current) =>
current ? { ...current, currentRevision } : current
);
setProjectDeviceUndo(null);
}
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !boardName.trim()) {
@@ -258,7 +264,7 @@ export default function ProjectDetailPage() {
async function handleCreateProjectDevice(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !projectDeviceForm.name.trim()) {
if (!projectId || !project || !projectDeviceForm.name.trim()) {
return;
}
@@ -279,8 +285,13 @@ export default function ProjectDetailPage() {
setIsSaving(true);
setError(null);
try {
const created = await createProjectDevice(projectId, payload);
setProjectDevices((current) => [...current, created]);
const result = await createProjectDevice(
projectId,
payload,
project.currentRevision
);
setProjectDevices((current) => [...current, result.projectDevice]);
applyProjectRevision(result.history.currentRevision);
setProjectDeviceForm({
name: emptyProjectDevice.name,
displayName: emptyProjectDevice.displayName,
@@ -302,14 +313,19 @@ export default function ProjectDetailPage() {
}
async function handleDeleteProjectDevice(projectDeviceId: string) {
if (!projectId) {
if (!projectId || !project) {
return;
}
setIsSaving(true);
setError(null);
try {
await deleteProjectDevice(projectId, projectDeviceId);
const result = await deleteProjectDevice(
projectId,
projectDeviceId,
project.currentRevision
);
setProjectDevices((current) => current.filter((item) => item.id !== projectDeviceId));
applyProjectRevision(result.history.currentRevision);
} catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht gelöscht werden.");
} finally {
@@ -322,7 +338,7 @@ export default function ProjectDetailPage() {
key: "name" | "displayName" | "category",
value: string
) {
if (!projectId) {
if (!projectId || !project) {
return;
}
@@ -342,9 +358,19 @@ export default function ProjectDetailPage() {
};
try {
const updated = await updateProjectDevice(projectId, device.id, payload);
setProjectDevices((current) => current.map((item) => (item.id === device.id ? updated : item)));
await openProjectDeviceSyncPreview(updated.id);
const result = await updateProjectDevice(
projectId,
device.id,
payload,
project.currentRevision
);
setProjectDevices((current) =>
current.map((item) =>
item.id === device.id ? result.projectDevice : item
)
);
applyProjectRevision(result.history.currentRevision);
await openProjectDeviceSyncPreview(result.projectDevice.id);
} catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
}
@@ -383,7 +409,13 @@ export default function ProjectDetailPage() {
}
async function handleSynchronizeProjectDeviceRows() {
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0) {
if (
!projectId ||
!project ||
!syncPreview ||
selectedSyncRowIds.length === 0 ||
selectedSyncFields.length === 0
) {
return;
}
setIsSaving(true);
@@ -393,16 +425,17 @@ export default function ProjectDetailPage() {
projectId,
syncPreview.projectDevice.id,
selectedSyncRowIds,
selectedSyncFields
selectedSyncFields,
project.currentRevision
);
setSyncPreview(result.preview);
applyProjectRevision(result.history.currentRevision);
setSelectedSyncRowIds(
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
);
setProjectDeviceUndo({
kind: "synchronize",
projectDeviceId: syncPreview.projectDevice.id,
rows: result.undo.rows,
revisionNumber: result.revision.revisionNumber,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
@@ -412,7 +445,12 @@ export default function ProjectDetailPage() {
}
async function handleDisconnectProjectDeviceRows() {
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0) {
if (
!projectId ||
!project ||
!syncPreview ||
selectedSyncRowIds.length === 0
) {
return;
}
setIsSaving(true);
@@ -421,13 +459,19 @@ export default function ProjectDetailPage() {
const result = await disconnectProjectDeviceRows(
projectId,
syncPreview.projectDevice.id,
selectedSyncRowIds
selectedSyncRowIds,
project.currentRevision
);
await openProjectDeviceSyncPreview(syncPreview.projectDevice.id);
setSyncPreview(result.preview);
setSelectedSyncRowIds(
result.preview.rows
.filter((row) => row.differences.length > 0)
.map((row) => row.rowId)
);
applyProjectRevision(result.history.currentRevision);
setProjectDeviceUndo({
kind: "disconnect",
projectDeviceId: syncPreview.projectDevice.id,
rowIds: result.undo.rowIds,
revisionNumber: result.revision.revisionNumber,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
@@ -437,24 +481,29 @@ export default function ProjectDetailPage() {
}
async function handleUndoProjectDeviceOperation() {
if (!projectId || !projectDeviceUndo) {
if (!projectId || !project || !projectDeviceUndo) {
return;
}
setIsSaving(true);
setError(null);
try {
const preview =
projectDeviceUndo.kind === "synchronize"
? await restoreProjectDeviceRows(
projectId,
projectDeviceUndo.projectDeviceId,
projectDeviceUndo.rows
)
: await reconnectProjectDeviceRows(
projectId,
projectDeviceUndo.projectDeviceId,
projectDeviceUndo.rowIds
);
if (
project.currentRevision !==
projectDeviceUndo.revisionNumber
) {
throw new Error(
"Eine neuere Projektänderung verhindert dieses Rückgängig."
);
}
const result = await undoProjectCommand(
projectId,
project.currentRevision
);
const preview = await getProjectDeviceSyncPreview(
projectId,
projectDeviceUndo.projectDeviceId
);
applyProjectRevision(result.history.currentRevision);
setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
setProjectDeviceUndo(null);
@@ -466,15 +515,20 @@ export default function ProjectDetailPage() {
}
async function handleCopyGlobalToProject() {
if (!projectId || !selectedGlobalDeviceId) {
if (!projectId || !project || !selectedGlobalDeviceId) {
return;
}
setIsSaving(true);
setError(null);
try {
const created = await copyGlobalDeviceToProject(projectId, selectedGlobalDeviceId);
setProjectDevices((current) => [...current, created]);
const result = await copyGlobalDeviceToProject(
projectId,
selectedGlobalDeviceId,
project.currentRevision
);
setProjectDevices((current) => [...current, result.projectDevice]);
applyProjectRevision(result.history.currentRevision);
} catch (err) {
setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden.");
} finally {
@@ -7,15 +7,12 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import {
toCircuitDeviceRowCreateValues,
toCircuitDeviceRowPatchValues,
toCircuitDeviceRowUpdateValues,
type CircuitDeviceRowCreateInput,
type CircuitDeviceRowPatchInput,
type CircuitDeviceRowUpdateInput,
} from "./circuit-device-row.persistence.js";
export type {
CircuitDeviceRowCreateInput,
CircuitDeviceRowPatchInput,
CircuitDeviceRowUpdateInput,
} from "./circuit-device-row.persistence.js";
export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
@@ -86,21 +83,6 @@ export class CircuitDeviceRowRepository {
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
}
async listLinkStatesByIds(projectId: string, rowIds: string[]) {
if (rowIds.length === 0) {
return [];
}
return db
.select({
id: circuitDeviceRows.id,
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuitDeviceRows.circuitId, circuits.id))
.innerJoin(circuitLists, eq(circuits.circuitListId, circuitLists.id))
.where(and(eq(circuitLists.projectId, projectId), inArray(circuitDeviceRows.id, rowIds)));
}
async create(input: CircuitDeviceRowCreateInput) {
const id = crypto.randomUUID();
await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input));
@@ -117,14 +99,6 @@ export class CircuitDeviceRowRepository {
.where(eq(circuitDeviceRows.id, rowId));
}
async updateFields(rowId: string, input: CircuitDeviceRowPatchInput) {
const values = toCircuitDeviceRowPatchValues(input);
if (Object.keys(values).length === 0) {
return;
}
await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId));
}
async delete(rowId: string) {
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
}
@@ -0,0 +1,242 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitSectionRenumberProjectCommand,
createCircuitSectionRenumberProjectCommand,
type CircuitSectionRenumberAssignment,
} from "../../domain/models/circuit-section-renumber-project-command.model.js";
import type {
CircuitSectionRenumberProjectCommandStore,
ExecuteCircuitSectionRenumberCommandInput,
} from "../../domain/ports/circuit-section-renumber-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 { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class CircuitSectionRenumberProjectCommandRepository
implements CircuitSectionRenumberProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitSectionRenumberCommandInput) {
assertCircuitSectionRenumberProjectCommand(input.command);
return this.database.transaction((tx) => {
const section = tx
.select({
id: circuitSections.id,
circuitListId: circuitSections.circuitListId,
projectId: circuitLists.projectId,
})
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(eq(circuitSections.id, input.command.payload.sectionId))
.get();
if (!section || section.projectId !== input.projectId) {
throw new Error(
"Circuit section does not belong to project."
);
}
const sectionCircuits = tx
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.where(eq(circuits.sectionId, section.id))
.all();
const assignments = input.command.payload.assignments;
if (
sectionCircuits.length !== assignments.length ||
sectionCircuits.some(
(circuit) =>
circuit.circuitListId !== section.circuitListId
)
) {
throw new Error(
"Circuit renumber must include every circuit in the section."
);
}
const circuitsById = new Map(
sectionCircuits.map((circuit) => [circuit.id, circuit])
);
for (const assignment of assignments) {
const circuit = circuitsById.get(assignment.circuitId);
if (
!circuit ||
circuit.equipmentIdentifier !==
assignment.expectedEquipmentIdentifier
) {
throw new Error(
"Circuit changed before section renumber execution."
);
}
}
const listCircuits = tx
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.where(eq(circuits.circuitListId, section.circuitListId))
.all();
const sectionCircuitIds = new Set(
assignments.map((assignment) => assignment.circuitId)
);
const targetIdentifiers = new Set(
assignments.map(
(assignment) => assignment.targetEquipmentIdentifier
)
);
const conflictingCircuit = listCircuits.find(
(circuit) =>
!sectionCircuitIds.has(circuit.id) &&
targetIdentifiers.has(circuit.equipmentIdentifier)
);
if (conflictingCircuit) {
throw new Error(
"Target equipment identifier already exists in circuit list."
);
}
const inverse = createCircuitSectionRenumberProjectCommand(
section.id,
assignments.map((assignment) => ({
circuitId: assignment.circuitId,
expectedEquipmentIdentifier:
assignment.targetEquipmentIdentifier,
targetEquipmentIdentifier:
assignment.expectedEquipmentIdentifier,
}))
);
const temporaryIdentifiers = this.createTemporaryIdentifiers(
section.id,
assignments,
listCircuits.map((circuit) => circuit.equipmentIdentifier)
);
for (const assignment of assignments) {
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const temporaryIdentifier = temporaryIdentifiers.get(
assignment.circuitId
)!;
const updated = tx
.update(circuits)
.set({ equipmentIdentifier: temporaryIdentifier })
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, section.id),
eq(circuits.circuitListId, section.circuitListId),
eq(
circuits.equipmentIdentifier,
assignment.expectedEquipmentIdentifier
)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during section renumber execution."
);
}
}
for (const assignment of assignments) {
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const temporaryIdentifier = temporaryIdentifiers.get(
assignment.circuitId
)!;
const updated = tx
.update(circuits)
.set({
equipmentIdentifier:
assignment.targetEquipmentIdentifier,
})
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, section.id),
eq(circuits.circuitListId, section.circuitListId),
eq(
circuits.equipmentIdentifier,
temporaryIdentifier
)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during final section renumber 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 createTemporaryIdentifiers(
sectionId: string,
assignments: CircuitSectionRenumberAssignment[],
occupiedIdentifiers: string[]
) {
const occupied = new Set([
...occupiedIdentifiers,
...assignments.map(
(assignment) => assignment.targetEquipmentIdentifier
),
]);
const temporaryIdentifiers = new Map<string, string>();
for (let index = 0; index < assignments.length; index += 1) {
const assignment = assignments[index];
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const base = `__tmp_renumber_command_${sectionId}_${index}`;
let candidate = base;
while (occupied.has(candidate)) {
candidate = `${candidate}_`;
}
occupied.add(candidate);
temporaryIdentifiers.set(assignment.circuitId, candidate);
}
return temporaryIdentifiers;
}
}
@@ -0,0 +1,135 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitSectionReorderProjectCommand,
createCircuitSectionReorderProjectCommand,
} from "../../domain/models/circuit-section-reorder-project-command.model.js";
import type {
CircuitSectionReorderProjectCommandStore,
ExecuteCircuitSectionReorderCommandInput,
} from "../../domain/ports/circuit-section-reorder-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 { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class CircuitSectionReorderProjectCommandRepository
implements CircuitSectionReorderProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitSectionReorderCommandInput) {
assertCircuitSectionReorderProjectCommand(input.command);
return this.database.transaction((tx) => {
const section = tx
.select({
id: circuitSections.id,
circuitListId: circuitSections.circuitListId,
projectId: circuitLists.projectId,
})
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(eq(circuitSections.id, input.command.payload.sectionId))
.get();
if (!section || section.projectId !== input.projectId) {
throw new Error(
"Circuit section does not belong to project."
);
}
const persistedCircuits = tx
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
sortOrder: circuits.sortOrder,
})
.from(circuits)
.where(eq(circuits.sectionId, section.id))
.all();
const assignments = input.command.payload.assignments;
if (
persistedCircuits.length !== assignments.length ||
persistedCircuits.some(
(circuit) =>
circuit.circuitListId !== section.circuitListId
)
) {
throw new Error(
"Circuit reorder must include every circuit in the section."
);
}
const circuitsById = new Map(
persistedCircuits.map((circuit) => [circuit.id, circuit])
);
for (const assignment of assignments) {
const circuit = circuitsById.get(assignment.circuitId);
if (
!circuit ||
circuit.sortOrder !== assignment.expectedSortOrder
) {
throw new Error(
"Circuit changed before section reorder execution."
);
}
}
const inverse = createCircuitSectionReorderProjectCommand(
section.id,
assignments.map((assignment) => ({
circuitId: assignment.circuitId,
expectedSortOrder: assignment.targetSortOrder,
targetSortOrder: assignment.expectedSortOrder,
}))
);
for (const assignment of assignments) {
if (
assignment.expectedSortOrder === assignment.targetSortOrder
) {
continue;
}
const updated = tx
.update(circuits)
.set({ sortOrder: assignment.targetSortOrder })
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, section.id),
eq(
circuits.sortOrder,
assignment.expectedSortOrder
)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during section reorder 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 };
});
}
}
-12
View File
@@ -4,18 +4,14 @@ 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,
CircuitPatchPersistenceInput,
} from "./circuit.persistence.js";
export {
toCircuitCreateValues,
toCircuitPatchValues,
} from "./circuit.persistence.js";
export class CircuitRepository {
@@ -84,14 +80,6 @@ export class CircuitRepository {
.where(eq(circuits.id, circuitId));
}
async updateFields(circuitId: string, input: CircuitPatchPersistenceInput) {
const values = toCircuitPatchValues(input);
if (Object.keys(values).length === 0) {
return;
}
await db.update(circuits).set(values).where(eq(circuits.id, circuitId));
}
async delete(circuitId: string) {
await db.delete(circuits).where(eq(circuits.id, circuitId));
}
@@ -0,0 +1,108 @@
import { and, eq } from "drizzle-orm";
import {
assertProjectDeviceUpdateProjectCommand,
createProjectDeviceUpdateProjectCommand,
type ProjectDeviceUpdateField,
type ProjectDeviceUpdatePatch,
type ProjectDeviceUpdateValues,
} from "../../domain/models/project-device-project-command.model.js";
import type {
ExecuteProjectDeviceUpdateCommandInput,
ProjectDeviceProjectCommandStore,
} from "../../domain/ports/project-device-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectDevices } from "../schema/project-devices.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type ProjectDeviceRow = typeof projectDevices.$inferSelect;
export class ProjectDeviceProjectCommandRepository
implements ProjectDeviceProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) {
assertProjectDeviceUpdateProjectCommand(input.command);
return this.database.transaction((tx) => {
const current = tx
.select()
.from(projectDevices)
.where(
and(
eq(
projectDevices.id,
input.command.payload.projectDeviceId
),
eq(projectDevices.projectId, input.projectId)
)
)
.get();
if (!current) {
throw new Error(
"Project device does not belong to project."
);
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as ProjectDeviceUpdatePatch;
const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
getProjectDeviceFieldValue(current, change.field),
])
) as ProjectDeviceUpdatePatch;
const inverse = createProjectDeviceUpdateProjectCommand(
current.id,
inversePatch
);
const updated = tx
.update(projectDevices)
.set(patch)
.where(
and(
eq(projectDevices.id, current.id),
eq(projectDevices.projectId, input.projectId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Project device changed before command execution."
);
}
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
}
function getProjectDeviceFieldValue<
TField extends ProjectDeviceUpdateField,
>(
projectDevice: ProjectDeviceRow,
field: TField
): ProjectDeviceUpdateValues[TField] {
return projectDevice[field] as ProjectDeviceUpdateValues[TField];
}
@@ -0,0 +1,151 @@
import { and, eq, inArray } from "drizzle-orm";
import {
assertProjectDeviceRowSyncProjectCommand,
createProjectDeviceRowSyncProjectCommand,
invertProjectDeviceRowSyncOperation,
projectDeviceSyncRowSnapshotFields,
type ProjectDeviceSyncRowSnapshot,
} from "../../domain/models/project-device-row-sync-project-command.model.js";
import type {
ExecuteProjectDeviceRowSyncCommandInput,
ProjectDeviceRowSyncProjectCommandStore,
} from "../../domain/ports/project-device-row-sync-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
export class ProjectDeviceRowSyncProjectCommandRepository
implements ProjectDeviceRowSyncProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectDeviceRowSyncCommandInput) {
assertProjectDeviceRowSyncProjectCommand(input.command);
return this.database.transaction((tx) => {
const projectDevice = tx
.select({ id: projectDevices.id })
.from(projectDevices)
.where(
and(
eq(
projectDevices.id,
input.command.payload.projectDeviceId
),
eq(projectDevices.projectId, input.projectId)
)
)
.get();
if (!projectDevice) {
throw new Error(
"Project device does not belong to project."
);
}
const rowIds = input.command.payload.rows.map(
(row) => row.rowId
);
const persistedRows = tx
.select({
row: circuitDeviceRows,
projectId: circuitLists.projectId,
})
.from(circuitDeviceRows)
.innerJoin(
circuits,
eq(circuits.id, circuitDeviceRows.circuitId)
)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (
persistedRows.length !== rowIds.length ||
persistedRows.some(
(persisted) => persisted.projectId !== input.projectId
)
) {
throw new Error(
"One or more sync rows do not belong to project."
);
}
const persistedById = new Map(
persistedRows.map((persisted) => [
persisted.row.id,
persisted.row,
])
);
for (const assignment of input.command.payload.rows) {
const persisted = persistedById.get(assignment.rowId);
if (
!persisted ||
!snapshotMatchesRow(assignment.expected, persisted)
) {
throw new Error(
"Project-device row changed before sync execution."
);
}
}
const inverse = createProjectDeviceRowSyncProjectCommand(
projectDevice.id,
invertProjectDeviceRowSyncOperation(
input.command.payload.operation
),
input.command.payload.rows.map((row) => ({
rowId: row.rowId,
expected: row.target,
target: row.expected,
}))
);
for (const assignment of input.command.payload.rows) {
const updated = tx
.update(circuitDeviceRows)
.set(assignment.target)
.where(eq(circuitDeviceRows.id, assignment.rowId))
.run();
if (updated.changes !== 1) {
throw new Error(
"Project-device row changed during sync execution."
);
}
}
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
}
function snapshotMatchesRow(
snapshot: ProjectDeviceSyncRowSnapshot,
row: CircuitDeviceRow
) {
return projectDeviceSyncRowSnapshotFields.every(
(field) => snapshot[field] === row[field]
);
}
@@ -1,81 +0,0 @@
import { and, eq, isNull } from "drizzle-orm";
import type {
ProjectDeviceLinkedRowValues,
ProjectDeviceRowSyncStore,
} from "../../domain/ports/project-device-row-sync.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { toCircuitDeviceRowUpdateValues } from "./circuit-device-row.persistence.js";
export class ProjectDeviceRowSyncRepository implements ProjectDeviceRowSyncStore {
constructor(private readonly database: AppDatabase) {}
updateLinkedRows(
projectDeviceId: string,
changes: Array<{ rowId: string; input: ProjectDeviceLinkedRowValues }>
) {
this.database.transaction((tx) => {
for (const change of changes) {
const result = tx
.update(circuitDeviceRows)
.set(toCircuitDeviceRowUpdateValues(change.input))
.where(
and(
eq(circuitDeviceRows.id, change.rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"A linked row changed before the operation could be completed."
);
}
}
});
}
disconnectLinkedRows(projectDeviceId: string, rowIds: string[]) {
this.database.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: null })
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"A linked row changed before the operation could be completed."
);
}
}
});
}
reconnectRows(projectDeviceId: string, rowIds: string[]) {
this.database.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: projectDeviceId })
.where(
and(
eq(circuitDeviceRows.id, rowId),
isNull(circuitDeviceRows.linkedProjectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"A disconnected row changed before the operation could be undone."
);
}
}
});
}
}
@@ -0,0 +1,339 @@
import {
and,
asc,
eq,
inArray,
isNull,
} from "drizzle-orm";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import {
assertProjectDeviceDeleteProjectCommand,
assertProjectDeviceInsertProjectCommand,
createProjectDeviceDeleteProjectCommand,
createProjectDeviceInsertProjectCommand,
projectDeviceDeleteCommandType,
projectDeviceInsertCommandType,
type ProjectDeviceSnapshot,
type ProjectDeviceStructureProjectCommand,
} from "../../domain/models/project-device-structure-project-command.model.js";
import type {
ExecuteProjectDeviceStructureCommandInput,
ProjectDeviceStructureProjectCommandStore,
} from "../../domain/ports/project-device-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
const circuitDeviceRowSnapshotFields = [
"id",
"circuitId",
"linkedProjectDeviceId",
"legacyConsumerId",
"sortOrder",
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"level",
"roomId",
"roomNumberSnapshot",
"roomNameSnapshot",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"overriddenFields",
] as const satisfies readonly (keyof CircuitDeviceRowSnapshot)[];
export class ProjectDeviceStructureProjectCommandRepository
implements ProjectDeviceStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectDeviceStructureCommandInput) {
return this.database.transaction((tx) => {
const inverse = this.applyCommand(
tx,
input.projectId,
input.source,
input.command
);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
private applyCommand(
database: AppDatabase,
projectId: string,
source: ExecuteProjectDeviceStructureCommandInput["source"],
command: ProjectDeviceStructureProjectCommand
): ProjectDeviceStructureProjectCommand {
if (command.type === projectDeviceInsertCommandType) {
assertProjectDeviceInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
command.payload.projectDevice,
command.payload.linkedRows
);
}
if (command.type === projectDeviceDeleteCommandType) {
assertProjectDeviceDeleteProjectCommand(command);
return this.delete(
database,
projectId,
command.payload.projectDeviceId,
command.payload.expectedProjectId
);
}
throw new Error("Unsupported project-device structure command.");
}
private insert(
database: AppDatabase,
projectId: string,
source: ExecuteProjectDeviceStructureCommandInput["source"],
snapshot: ProjectDeviceSnapshot,
linkedRows: CircuitDeviceRowSnapshot[]
) {
if (snapshot.projectId !== projectId) {
throw new Error(
"Project-device snapshot does not belong to project."
);
}
if (source === "user" && linkedRows.length > 0) {
throw new Error(
"User project-device inserts cannot reconnect existing rows."
);
}
const project = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project does not exist.");
}
const existing = database
.select({ id: projectDevices.id })
.from(projectDevices)
.where(eq(projectDevices.id, snapshot.id))
.get();
if (existing) {
throw new Error("Project-device id already exists.");
}
this.assertRestorableRows(database, projectId, linkedRows);
database.insert(projectDevices).values(snapshot).run();
for (const row of linkedRows) {
const updated = database
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: snapshot.id })
.where(
and(
eq(circuitDeviceRows.id, row.id),
isNull(circuitDeviceRows.linkedProjectDeviceId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"A restored project-device row changed during insertion."
);
}
}
return createProjectDeviceDeleteProjectCommand(
snapshot.id,
projectId
);
}
private delete(
database: AppDatabase,
projectId: string,
projectDeviceId: string,
expectedProjectId: string
) {
if (expectedProjectId !== projectId) {
throw new Error(
"Project-device delete project does not match command project."
);
}
const projectDevice = database
.select()
.from(projectDevices)
.where(
and(
eq(projectDevices.id, projectDeviceId),
eq(projectDevices.projectId, projectId)
)
)
.get();
if (!projectDevice) {
throw new Error(
"Project device does not belong to project."
);
}
const linkedRows = database
.select({
row: circuitDeviceRows,
projectId: circuitLists.projectId,
})
.from(circuitDeviceRows)
.innerJoin(
circuits,
eq(circuits.id, circuitDeviceRows.circuitId)
)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(
eq(
circuitDeviceRows.linkedProjectDeviceId,
projectDevice.id
)
)
.orderBy(
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
if (
linkedRows.some((linked) => linked.projectId !== projectId)
) {
throw new Error(
"Project device has linked rows outside its project."
);
}
const inverse = createProjectDeviceInsertProjectCommand(
toProjectDeviceSnapshot(projectDevice),
linkedRows.map(({ row }) => ({
...toCircuitDeviceRowSnapshot(row),
linkedProjectDeviceId: null,
}))
);
const deleted = database
.delete(projectDevices)
.where(
and(
eq(projectDevices.id, projectDevice.id),
eq(projectDevices.projectId, projectId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Project device could not be deleted.");
}
return inverse;
}
private assertRestorableRows(
database: AppDatabase,
projectId: string,
snapshots: CircuitDeviceRowSnapshot[]
) {
if (snapshots.length === 0) {
return;
}
const rowIds = snapshots.map((row) => row.id);
const persistedRows = database
.select({
row: circuitDeviceRows,
projectId: circuitLists.projectId,
})
.from(circuitDeviceRows)
.innerJoin(
circuits,
eq(circuits.id, circuitDeviceRows.circuitId)
)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (
persistedRows.length !== snapshots.length ||
persistedRows.some(
(persisted) => persisted.projectId !== projectId
)
) {
throw new Error(
"One or more restored rows do not belong to project."
);
}
const persistedById = new Map(
persistedRows.map(({ row }) => [
row.id,
toCircuitDeviceRowSnapshot(row),
])
);
for (const snapshot of snapshots) {
const persisted = persistedById.get(snapshot.id);
if (
!persisted ||
!circuitDeviceRowSnapshotFields.every(
(field) => persisted[field] === snapshot[field]
)
) {
throw new Error(
"A restored project-device row changed before insertion."
);
}
}
}
}
function toProjectDeviceSnapshot(
projectDevice: typeof projectDevices.$inferSelect
): ProjectDeviceSnapshot {
if (
projectDevice.phaseType !== "single_phase" &&
projectDevice.phaseType !== "three_phase"
) {
throw new Error("Persisted project-device phase type is invalid.");
}
return {
id: projectDevice.id,
projectId: projectDevice.projectId,
name: projectDevice.name,
displayName: projectDevice.displayName,
phaseType: projectDevice.phaseType,
connectionKind: projectDevice.connectionKind,
costGroup: projectDevice.costGroup,
category: projectDevice.category,
quantity: projectDevice.quantity,
powerPerUnit: projectDevice.powerPerUnit,
simultaneityFactor: projectDevice.simultaneityFactor,
cosPhi: projectDevice.cosPhi,
remark: projectDevice.remark,
voltageV: projectDevice.voltageV,
};
}
@@ -1,12 +1,7 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { db } from "../client.js";
import { projectDevices } from "../schema/project-devices.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
import type {
CreateProjectDeviceInput,
UpdateProjectDeviceInput,
} from "../../shared/validation/project-device.schemas.js";
function withTotalPower(row: typeof projectDevices.$inferSelect) {
return {
@@ -21,31 +16,6 @@ export class ProjectDeviceRepository {
return rows.map(withTotalPower);
}
async create(projectId: string, input: CreateProjectDeviceInput) {
const id = crypto.randomUUID();
await db.insert(projectDevices).values({
id,
projectId,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
voltageV: input.voltageV ?? null,
});
const created = await this.findById(projectId, id);
if (!created) {
throw new Error("Failed to create project device.");
}
return created;
}
async findById(projectId: string, projectDeviceId: string) {
const [row] = await db
.select()
@@ -57,33 +27,4 @@ export class ProjectDeviceRepository {
return row ? withTotalPower(row) : null;
}
async update(projectId: string, projectDeviceId: string, input: UpdateProjectDeviceInput) {
await db
.update(projectDevices)
.set({
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
voltageV: input.voltageV ?? null,
})
.where(
and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId))
);
}
async delete(projectId: string, projectDeviceId: string) {
await db
.delete(projectDevices)
.where(
and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId))
);
}
}
@@ -0,0 +1,130 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitSectionRenumberCommandType =
"circuit.renumber-section" as const;
export const circuitSectionRenumberCommandSchemaVersion = 1 as const;
export interface CircuitSectionRenumberAssignment {
circuitId: string;
expectedEquipmentIdentifier: string;
targetEquipmentIdentifier: string;
}
export interface CircuitSectionRenumberCommandPayload {
sectionId: string;
assignments: CircuitSectionRenumberAssignment[];
}
export interface CircuitSectionRenumberProjectCommand
extends SerializedProjectCommand<CircuitSectionRenumberCommandPayload> {
schemaVersion: typeof circuitSectionRenumberCommandSchemaVersion;
type: typeof circuitSectionRenumberCommandType;
}
export function createCircuitSectionRenumberProjectCommand(
sectionId: string,
assignments: CircuitSectionRenumberAssignment[]
): CircuitSectionRenumberProjectCommand {
const command: CircuitSectionRenumberProjectCommand = {
schemaVersion: circuitSectionRenumberCommandSchemaVersion,
type: circuitSectionRenumberCommandType,
payload: { sectionId, assignments },
};
assertCircuitSectionRenumberProjectCommand(command);
return command;
}
export function assertCircuitSectionRenumberProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitSectionRenumberProjectCommand {
if (
command.schemaVersion !==
circuitSectionRenumberCommandSchemaVersion ||
command.type !== circuitSectionRenumberCommandType
) {
throw new Error("Unsupported circuit section renumber command.");
}
if (!isPlainObject(command.payload)) {
throw new Error(
"Circuit section renumber payload must be an object."
);
}
const { sectionId, assignments } = command.payload;
assertNonEmptyString(sectionId, "sectionId");
if (!Array.isArray(assignments) || assignments.length === 0) {
throw new Error(
"Circuit section renumber command requires assignments."
);
}
const circuitIds = new Set<string>();
const expectedIdentifiers = new Set<string>();
const targetIdentifiers = new Set<string>();
let hasChangedIdentifier = false;
for (const assignment of assignments) {
if (!isPlainObject(assignment)) {
throw new Error(
"Circuit section renumber command contains an invalid assignment."
);
}
assertNonEmptyString(assignment.circuitId, "circuitId");
assertNonEmptyString(
assignment.expectedEquipmentIdentifier,
"expectedEquipmentIdentifier"
);
assertNonEmptyString(
assignment.targetEquipmentIdentifier,
"targetEquipmentIdentifier"
);
if (circuitIds.has(assignment.circuitId)) {
throw new Error(
"Circuit section renumber command contains duplicate circuit ids."
);
}
if (
expectedIdentifiers.has(
assignment.expectedEquipmentIdentifier
)
) {
throw new Error(
"Circuit section renumber command contains duplicate expected identifiers."
);
}
if (
targetIdentifiers.has(assignment.targetEquipmentIdentifier)
) {
throw new Error(
"Circuit section renumber command contains duplicate target identifiers."
);
}
if (
assignment.expectedEquipmentIdentifier !==
assignment.targetEquipmentIdentifier
) {
hasChangedIdentifier = true;
}
circuitIds.add(assignment.circuitId);
expectedIdentifiers.add(
assignment.expectedEquipmentIdentifier
);
targetIdentifiers.add(assignment.targetEquipmentIdentifier);
}
if (!hasChangedIdentifier) {
throw new Error(
"Circuit section renumber command must change at least one identifier."
);
}
}
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 isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,115 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitSectionReorderCommandType =
"circuit.reorder-section" as const;
export const circuitSectionReorderCommandSchemaVersion = 1 as const;
export interface CircuitSectionReorderAssignment {
circuitId: string;
expectedSortOrder: number;
targetSortOrder: number;
}
export interface CircuitSectionReorderCommandPayload {
sectionId: string;
assignments: CircuitSectionReorderAssignment[];
}
export interface CircuitSectionReorderProjectCommand
extends SerializedProjectCommand<CircuitSectionReorderCommandPayload> {
schemaVersion: typeof circuitSectionReorderCommandSchemaVersion;
type: typeof circuitSectionReorderCommandType;
}
export function createCircuitSectionReorderProjectCommand(
sectionId: string,
assignments: CircuitSectionReorderAssignment[]
): CircuitSectionReorderProjectCommand {
const command: CircuitSectionReorderProjectCommand = {
schemaVersion: circuitSectionReorderCommandSchemaVersion,
type: circuitSectionReorderCommandType,
payload: { sectionId, assignments },
};
assertCircuitSectionReorderProjectCommand(command);
return command;
}
export function assertCircuitSectionReorderProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitSectionReorderProjectCommand {
if (
command.schemaVersion !== circuitSectionReorderCommandSchemaVersion ||
command.type !== circuitSectionReorderCommandType
) {
throw new Error("Unsupported circuit section reorder command.");
}
if (!isPlainObject(command.payload)) {
throw new Error(
"Circuit section reorder payload must be an object."
);
}
const { sectionId, assignments } = command.payload;
assertNonEmptyString(sectionId, "sectionId");
if (!Array.isArray(assignments) || assignments.length === 0) {
throw new Error(
"Circuit section reorder command requires assignments."
);
}
const circuitIds = new Set<string>();
let hasChangedPosition = false;
for (const assignment of assignments) {
if (!isPlainObject(assignment)) {
throw new Error(
"Circuit section reorder command contains an invalid assignment."
);
}
assertNonEmptyString(assignment.circuitId, "circuitId");
assertFiniteNumber(
assignment.expectedSortOrder,
"expectedSortOrder"
);
assertFiniteNumber(
assignment.targetSortOrder,
"targetSortOrder"
);
if (circuitIds.has(assignment.circuitId)) {
throw new Error(
"Circuit section reorder command contains duplicate circuit ids."
);
}
if (
assignment.expectedSortOrder !== assignment.targetSortOrder
) {
hasChangedPosition = true;
}
circuitIds.add(assignment.circuitId);
}
if (!hasChangedPosition) {
throw new Error(
"Circuit section reorder command must change at least one position."
);
}
}
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);
}
+1
View File
@@ -57,6 +57,7 @@ export interface CircuitTreeSectionBlock {
export interface CircuitTreeResponse {
circuitListId: string;
currentRevision: number;
sections: CircuitTreeSectionBlock[];
}
@@ -0,0 +1,198 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectDeviceUpdateCommandType =
"project-device.update" as const;
export const projectDeviceUpdateCommandSchemaVersion = 1 as const;
export interface ProjectDeviceUpdateValues {
name: string;
displayName: string;
phaseType: "single_phase" | "three_phase";
connectionKind: string | null;
costGroup: string | null;
category: string | null;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi: number | null;
remark: string | null;
voltageV: number | null;
}
export type ProjectDeviceUpdateField =
keyof ProjectDeviceUpdateValues;
export type ProjectDeviceUpdatePatch =
Partial<ProjectDeviceUpdateValues>;
export const projectDeviceUpdateFields = [
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"voltageV",
] as const satisfies readonly ProjectDeviceUpdateField[];
export interface ProjectDeviceUpdateFieldChange<
TField extends ProjectDeviceUpdateField = ProjectDeviceUpdateField,
> {
field: TField;
value: ProjectDeviceUpdateValues[TField];
}
export interface ProjectDeviceUpdateCommandPayload {
projectDeviceId: string;
changes: ProjectDeviceUpdateFieldChange[];
}
export interface ProjectDeviceUpdateProjectCommand
extends SerializedProjectCommand<ProjectDeviceUpdateCommandPayload> {
schemaVersion: typeof projectDeviceUpdateCommandSchemaVersion;
type: typeof projectDeviceUpdateCommandType;
}
export function createProjectDeviceUpdateProjectCommand(
projectDeviceId: string,
patch: ProjectDeviceUpdatePatch
): ProjectDeviceUpdateProjectCommand {
const command: ProjectDeviceUpdateProjectCommand = {
schemaVersion: projectDeviceUpdateCommandSchemaVersion,
type: projectDeviceUpdateCommandType,
payload: {
projectDeviceId,
changes: Object.entries(patch).map(([field, value]) => ({
field: field as ProjectDeviceUpdateField,
value,
})) as ProjectDeviceUpdateFieldChange[],
},
};
assertProjectDeviceUpdateProjectCommand(command);
return command;
}
export function assertProjectDeviceUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectDeviceUpdateProjectCommand {
if (
command.schemaVersion !== projectDeviceUpdateCommandSchemaVersion ||
command.type !== projectDeviceUpdateCommandType
) {
throw new Error("Unsupported project-device update command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Project-device update payload must be an object.");
}
const { projectDeviceId, changes } = command.payload;
if (
typeof projectDeviceId !== "string" ||
!projectDeviceId.trim()
) {
throw new Error(
"Project-device update command requires a project device id."
);
}
if (!Array.isArray(changes) || changes.length === 0) {
throw new Error(
"Project-device update command requires at least one change."
);
}
const seenFields = new Set<string>();
for (const change of changes) {
if (!isPlainObject(change) || typeof change.field !== "string") {
throw new Error(
"Project-device update command contains an invalid change."
);
}
if (
!isProjectDeviceUpdateField(change.field) ||
seenFields.has(change.field)
) {
throw new Error(
"Project-device update command contains an invalid or duplicate field."
);
}
assertProjectDeviceUpdateFieldValue(
change.field,
change.value
);
seenFields.add(change.field);
}
}
function assertProjectDeviceUpdateFieldValue(
field: ProjectDeviceUpdateField,
value: unknown
) {
if (field === "name" || field === "displayName") {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
return;
}
if (field === "phaseType") {
if (value !== "single_phase" && value !== "three_phase") {
throw new Error("phaseType is invalid.");
}
return;
}
if (
field === "quantity" ||
field === "powerPerUnit" ||
field === "simultaneityFactor"
) {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < 0 ||
(field === "simultaneityFactor" && value > 1)
) {
throw new Error(`${field} is outside its allowed range.`);
}
return;
}
if (field === "cosPhi") {
if (
value !== null &&
(typeof value !== "number" ||
!Number.isFinite(value) ||
value < 0 ||
value > 1)
) {
throw new Error("cosPhi must be between zero and one or null.");
}
return;
}
if (field === "voltageV") {
if (
value !== null &&
(typeof value !== "number" ||
!Number.isFinite(value) ||
value <= 0)
) {
throw new Error("voltageV must be positive or null.");
}
return;
}
if (value !== null && typeof value !== "string") {
throw new Error(`${field} must be a string or null.`);
}
}
function isProjectDeviceUpdateField(
value: string
): value is ProjectDeviceUpdateField {
return projectDeviceUpdateFields.includes(
value as ProjectDeviceUpdateField
);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,245 @@
import {
assertCircuitDeviceRowUpdateProjectCommand,
circuitDeviceRowUpdateCommandSchemaVersion,
circuitDeviceRowUpdateCommandType,
type CircuitDeviceRowUpdateField,
type CircuitDeviceRowUpdateValues,
} from "./circuit-device-row-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectDeviceRowSyncCommandType =
"project-device.sync-rows" as const;
export const projectDeviceRowSyncCommandSchemaVersion = 1 as const;
export const projectDeviceSyncRowSnapshotFields = [
"linkedProjectDeviceId",
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"overriddenFields",
] as const satisfies readonly CircuitDeviceRowUpdateField[];
export type ProjectDeviceSyncRowSnapshot = Pick<
CircuitDeviceRowUpdateValues,
(typeof projectDeviceSyncRowSnapshotFields)[number]
>;
export type ProjectDeviceRowSyncOperation =
| "synchronize"
| "disconnect"
| "reconnect";
export interface ProjectDeviceRowSyncAssignment {
rowId: string;
expected: ProjectDeviceSyncRowSnapshot;
target: ProjectDeviceSyncRowSnapshot;
}
export interface ProjectDeviceRowSyncCommandPayload {
projectDeviceId: string;
operation: ProjectDeviceRowSyncOperation;
rows: ProjectDeviceRowSyncAssignment[];
}
export interface ProjectDeviceRowSyncProjectCommand
extends SerializedProjectCommand<ProjectDeviceRowSyncCommandPayload> {
schemaVersion: typeof projectDeviceRowSyncCommandSchemaVersion;
type: typeof projectDeviceRowSyncCommandType;
}
export function createProjectDeviceRowSyncProjectCommand(
projectDeviceId: string,
operation: ProjectDeviceRowSyncOperation,
rows: ProjectDeviceRowSyncAssignment[]
): ProjectDeviceRowSyncProjectCommand {
const command: ProjectDeviceRowSyncProjectCommand = {
schemaVersion: projectDeviceRowSyncCommandSchemaVersion,
type: projectDeviceRowSyncCommandType,
payload: { projectDeviceId, operation, rows },
};
assertProjectDeviceRowSyncProjectCommand(command);
return command;
}
export function assertProjectDeviceRowSyncProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectDeviceRowSyncProjectCommand {
if (
command.schemaVersion !== projectDeviceRowSyncCommandSchemaVersion ||
command.type !== projectDeviceRowSyncCommandType
) {
throw new Error("Unsupported project-device row sync command.");
}
if (!isPlainObject(command.payload)) {
throw new Error(
"Project-device row sync payload must be an object."
);
}
const { projectDeviceId, operation, rows } = command.payload;
assertNonEmptyString(projectDeviceId, "projectDeviceId");
if (
operation !== "synchronize" &&
operation !== "disconnect" &&
operation !== "reconnect"
) {
throw new Error(
"Project-device row sync operation is invalid."
);
}
if (!Array.isArray(rows) || rows.length === 0) {
throw new Error(
"Project-device row sync command requires at least one row."
);
}
const rowIds = new Set<string>();
for (const row of rows) {
if (!isPlainObject(row)) {
throw new Error(
"Project-device row sync command contains an invalid row."
);
}
assertNonEmptyString(row.rowId, "rowId");
if (rowIds.has(row.rowId)) {
throw new Error(
"Project-device row sync command contains duplicate row ids."
);
}
assertProjectDeviceSyncRowSnapshot(row.expected);
assertProjectDeviceSyncRowSnapshot(row.target);
const expected =
row.expected as ProjectDeviceSyncRowSnapshot;
const target = row.target as ProjectDeviceSyncRowSnapshot;
if (snapshotsEqual(expected, target)) {
throw new Error(
"Project-device row sync command contains a no-op row."
);
}
assertOperationTransition(
operation,
projectDeviceId,
expected,
target
);
rowIds.add(row.rowId);
}
}
export function invertProjectDeviceRowSyncOperation(
operation: ProjectDeviceRowSyncOperation
): ProjectDeviceRowSyncOperation {
if (operation === "disconnect") {
return "reconnect";
}
if (operation === "reconnect") {
return "disconnect";
}
return "synchronize";
}
function assertProjectDeviceSyncRowSnapshot(snapshot: unknown) {
if (!isPlainObject(snapshot)) {
throw new Error(
"Project-device sync row snapshot must be an object."
);
}
const snapshotKeys = Object.keys(snapshot);
if (
snapshotKeys.length !== projectDeviceSyncRowSnapshotFields.length ||
projectDeviceSyncRowSnapshotFields.some(
(field) =>
!Object.prototype.hasOwnProperty.call(snapshot, field)
)
) {
throw new Error(
"Project-device sync row snapshot must contain every sync field."
);
}
assertCircuitDeviceRowUpdateProjectCommand({
schemaVersion: circuitDeviceRowUpdateCommandSchemaVersion,
type: circuitDeviceRowUpdateCommandType,
payload: {
rowId: "__sync_snapshot_validation__",
changes: projectDeviceSyncRowSnapshotFields.map((field) => ({
field,
value: snapshot[field],
})),
},
});
}
function assertOperationTransition(
operation: ProjectDeviceRowSyncOperation,
projectDeviceId: string,
expected: ProjectDeviceSyncRowSnapshot,
target: ProjectDeviceSyncRowSnapshot
) {
if (operation === "synchronize") {
if (
expected.linkedProjectDeviceId !== projectDeviceId ||
target.linkedProjectDeviceId !== projectDeviceId
) {
throw new Error(
"Synchronized rows must remain linked to the selected project device."
);
}
return;
}
const expectedLink =
operation === "disconnect" ? projectDeviceId : null;
const targetLink =
operation === "disconnect" ? null : projectDeviceId;
if (
expected.linkedProjectDeviceId !== expectedLink ||
target.linkedProjectDeviceId !== targetLink
) {
throw new Error(
"Project-device link transition does not match the operation."
);
}
if (!snapshotsEqualWithoutLink(expected, target)) {
throw new Error(
"Disconnect and reconnect must not change local row values."
);
}
}
function snapshotsEqual(
left: ProjectDeviceSyncRowSnapshot,
right: ProjectDeviceSyncRowSnapshot
) {
return projectDeviceSyncRowSnapshotFields.every(
(field) => left[field] === right[field]
);
}
function snapshotsEqualWithoutLink(
left: ProjectDeviceSyncRowSnapshot,
right: ProjectDeviceSyncRowSnapshot
) {
return projectDeviceSyncRowSnapshotFields
.filter((field) => field !== "linkedProjectDeviceId")
.every((field) => left[field] === right[field]);
}
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 isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,171 @@
import {
assertCircuitDeviceRowInsertProjectCommand,
circuitDeviceRowInsertCommandType,
circuitDeviceRowStructureCommandSchemaVersion,
type CircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure-project-command.model.js";
import {
assertProjectDeviceUpdateProjectCommand,
projectDeviceUpdateCommandSchemaVersion,
projectDeviceUpdateCommandType,
projectDeviceUpdateFields,
type ProjectDeviceUpdateValues,
} from "./project-device-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectDeviceInsertCommandType =
"project-device.insert" as const;
export const projectDeviceDeleteCommandType =
"project-device.delete" as const;
export const projectDeviceStructureCommandSchemaVersion = 1 as const;
export interface ProjectDeviceSnapshot
extends ProjectDeviceUpdateValues {
id: string;
projectId: string;
}
export interface ProjectDeviceInsertCommandPayload {
projectDevice: ProjectDeviceSnapshot;
linkedRows: CircuitDeviceRowSnapshot[];
}
export interface ProjectDeviceDeleteCommandPayload {
projectDeviceId: string;
expectedProjectId: string;
}
export interface ProjectDeviceInsertProjectCommand
extends SerializedProjectCommand<ProjectDeviceInsertCommandPayload> {
schemaVersion: typeof projectDeviceStructureCommandSchemaVersion;
type: typeof projectDeviceInsertCommandType;
}
export interface ProjectDeviceDeleteProjectCommand
extends SerializedProjectCommand<ProjectDeviceDeleteCommandPayload> {
schemaVersion: typeof projectDeviceStructureCommandSchemaVersion;
type: typeof projectDeviceDeleteCommandType;
}
export type ProjectDeviceStructureProjectCommand =
| ProjectDeviceInsertProjectCommand
| ProjectDeviceDeleteProjectCommand;
export function createProjectDeviceInsertProjectCommand(
projectDevice: ProjectDeviceSnapshot,
linkedRows: CircuitDeviceRowSnapshot[] = []
): ProjectDeviceInsertProjectCommand {
const command: ProjectDeviceInsertProjectCommand = {
schemaVersion: projectDeviceStructureCommandSchemaVersion,
type: projectDeviceInsertCommandType,
payload: { projectDevice, linkedRows },
};
assertProjectDeviceInsertProjectCommand(command);
return command;
}
export function createProjectDeviceDeleteProjectCommand(
projectDeviceId: string,
expectedProjectId: string
): ProjectDeviceDeleteProjectCommand {
const command: ProjectDeviceDeleteProjectCommand = {
schemaVersion: projectDeviceStructureCommandSchemaVersion,
type: projectDeviceDeleteCommandType,
payload: { projectDeviceId, expectedProjectId },
};
assertProjectDeviceDeleteProjectCommand(command);
return command;
}
export function assertProjectDeviceInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectDeviceInsertProjectCommand {
if (
command.schemaVersion !== projectDeviceStructureCommandSchemaVersion ||
command.type !== projectDeviceInsertCommandType
) {
throw new Error("Unsupported project-device insert command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Project-device insert payload must be an object.");
}
const { projectDevice, linkedRows } = command.payload;
if (!isPlainObject(projectDevice)) {
throw new Error(
"Project-device insert command requires a project device."
);
}
assertNonEmptyString(projectDevice.id, "projectDevice.id");
assertNonEmptyString(
projectDevice.projectId,
"projectDevice.projectId"
);
assertProjectDeviceUpdateProjectCommand({
schemaVersion: projectDeviceUpdateCommandSchemaVersion,
type: projectDeviceUpdateCommandType,
payload: {
projectDeviceId: projectDevice.id,
changes: projectDeviceUpdateFields.map((field) => ({
field,
value: projectDevice[field],
})),
},
});
if (!Array.isArray(linkedRows)) {
throw new Error(
"Project-device insert linkedRows must be an array."
);
}
const rowIds = new Set<string>();
for (const row of linkedRows) {
assertCircuitDeviceRowInsertProjectCommand({
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType,
payload: { row },
});
if (row.linkedProjectDeviceId !== null) {
throw new Error(
"Restored project-device rows must currently be disconnected."
);
}
if (rowIds.has(row.id)) {
throw new Error(
"Project-device insert command contains duplicate row ids."
);
}
rowIds.add(row.id);
}
}
export function assertProjectDeviceDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectDeviceDeleteProjectCommand {
if (
command.schemaVersion !== projectDeviceStructureCommandSchemaVersion ||
command.type !== projectDeviceDeleteCommandType
) {
throw new Error("Unsupported project-device delete command.");
}
if (!isPlainObject(command.payload)) {
throw new Error("Project-device delete payload must be an object.");
}
assertNonEmptyString(
command.payload.projectDeviceId,
"projectDeviceId"
);
assertNonEmptyString(
command.payload.expectedProjectId,
"expectedProjectId"
);
}
function assertNonEmptyString(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,26 @@
import type { CircuitSectionRenumberProjectCommand } from "../models/circuit-section-renumber-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitSectionRenumberCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitSectionRenumberProjectCommand;
}
export interface ExecutedCircuitSectionRenumberCommand {
revision: AppendedProjectRevision;
inverse: CircuitSectionRenumberProjectCommand;
}
export interface CircuitSectionRenumberProjectCommandStore {
execute(
input: ExecuteCircuitSectionRenumberCommandInput
): ExecutedCircuitSectionRenumberCommand;
}
@@ -0,0 +1,26 @@
import type { CircuitSectionReorderProjectCommand } from "../models/circuit-section-reorder-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitSectionReorderCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitSectionReorderProjectCommand;
}
export interface ExecutedCircuitSectionReorderCommand {
revision: AppendedProjectRevision;
inverse: CircuitSectionReorderProjectCommand;
}
export interface CircuitSectionReorderProjectCommandStore {
execute(
input: ExecuteCircuitSectionReorderCommandInput
): ExecutedCircuitSectionReorderCommand;
}
@@ -0,0 +1,26 @@
import type { ProjectDeviceUpdateProjectCommand } from "../models/project-device-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteProjectDeviceUpdateCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ProjectDeviceUpdateProjectCommand;
}
export interface ExecutedProjectDeviceUpdateCommand {
revision: AppendedProjectRevision;
inverse: ProjectDeviceUpdateProjectCommand;
}
export interface ProjectDeviceProjectCommandStore {
executeUpdate(
input: ExecuteProjectDeviceUpdateCommandInput
): ExecutedProjectDeviceUpdateCommand;
}
@@ -0,0 +1,26 @@
import type { ProjectDeviceRowSyncProjectCommand } from "../models/project-device-row-sync-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteProjectDeviceRowSyncCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ProjectDeviceRowSyncProjectCommand;
}
export interface ExecutedProjectDeviceRowSyncCommand {
revision: AppendedProjectRevision;
inverse: ProjectDeviceRowSyncProjectCommand;
}
export interface ProjectDeviceRowSyncProjectCommandStore {
execute(
input: ExecuteProjectDeviceRowSyncCommandInput
): ExecutedProjectDeviceRowSyncCommand;
}
@@ -1,28 +0,0 @@
export interface ProjectDeviceLinkedRowValues {
linkedProjectDeviceId?: string;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}
export interface ProjectDeviceRowSyncStore {
updateLinkedRows(
projectDeviceId: string,
changes: Array<{ rowId: string; input: ProjectDeviceLinkedRowValues }>
): void;
disconnectLinkedRows(projectDeviceId: string, rowIds: string[]): void;
reconnectRows(projectDeviceId: string, rowIds: string[]): void;
}
@@ -0,0 +1,26 @@
import type { ProjectDeviceStructureProjectCommand } from "../models/project-device-structure-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteProjectDeviceStructureCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ProjectDeviceStructureProjectCommand;
}
export interface ExecutedProjectDeviceStructureCommand {
revision: AppendedProjectRevision;
inverse: ProjectDeviceStructureProjectCommand;
}
export interface ProjectDeviceStructureProjectCommandStore {
execute(
input: ExecuteProjectDeviceStructureCommandInput
): ExecutedProjectDeviceStructureCommand;
}
@@ -1,51 +1,37 @@
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js";
import type { CircuitSectionTransactionStore } from "../ports/circuit-section-transaction.store.js";
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import type {
CreateCircuitDeviceRowInput,
CreateCircuitInput,
CreateCircuitWithDeviceRowsInput,
MoveCircuitDeviceRowInput,
MoveCircuitDeviceRowsBulkInput,
ReorderSectionCircuitsInput,
UpdateSectionEquipmentIdentifiersInput,
UpdateCircuitDeviceRowInput,
UpdateCircuitInput,
} from "../../shared/validation/circuit.schemas.js";
import { CircuitNumberingService } from "./circuit-numbering.service.js";
import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js";
export class CircuitWriteService {
private readonly circuitRepository: CircuitRepository;
private readonly circuitSectionRepository: CircuitSectionRepository;
private readonly circuitListRepository: CircuitListRepository;
private readonly deviceRowRepository: CircuitDeviceRowRepository;
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
private readonly projectDeviceRepository: ProjectDeviceRepository;
private readonly numberingService: CircuitNumberingService;
constructor(deps?: {
circuitRepository?: CircuitRepository;
circuitSectionRepository?: CircuitSectionRepository;
circuitListRepository?: CircuitListRepository;
deviceRowRepository?: CircuitDeviceRowRepository;
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
projectDeviceRepository?: ProjectDeviceRepository;
numberingService?: CircuitNumberingService;
}) {
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository();
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
}
@@ -61,36 +47,6 @@ export class CircuitWriteService {
return section;
}
// Enforces BMK uniqueness inside one circuit list.
private async assertUniqueEquipmentIdentifier(
circuitListId: string,
equipmentIdentifier: string,
excludeCircuitId?: string
) {
const exists = await this.circuitRepository.existsByEquipmentIdentifier(
circuitListId,
equipmentIdentifier,
excludeCircuitId
);
if (exists) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
}
// Validates linked project-device id against owning project of the circuit list.
private async assertValidLinkedProjectDeviceForProject(
projectId: string,
linkedProjectDeviceId?: string
) {
if (!linkedProjectDeviceId) {
return;
}
const device = await this.projectDeviceRepository.findById(projectId, linkedProjectDeviceId);
if (!device) {
throw new Error("Invalid linked project device id.");
}
}
private getDeviceRowTransactionStore() {
if (!this.deviceRowTransactionStore) {
throw new Error("Circuit device-row transactions are not configured.");
@@ -105,107 +61,6 @@ export class CircuitWriteService {
return this.circuitSectionTransactionStore;
}
private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) {
if (!linkedProjectDeviceId) {
return;
}
const circuit = await this.circuitRepository.findById(circuitId);
if (!circuit) {
throw new Error("Invalid circuit id.");
}
const list = await this.circuitListRepository.findByIdByListIdOnly(circuit.circuitListId);
if (!list) {
throw new Error("Circuit list not found.");
}
await this.assertValidLinkedProjectDeviceForProject(list.projectId, linkedProjectDeviceId);
}
async createCircuit(projectId: string, circuitListId: string, input: CreateCircuitInput) {
const list = await this.circuitListRepository.findById(projectId, circuitListId);
if (!list) {
throw new Error("Circuit list not found in project.");
}
await this.assertSectionInList(input.sectionId, circuitListId);
await this.assertUniqueEquipmentIdentifier(circuitListId, input.equipmentIdentifier);
const id = await this.circuitRepository.create({
circuitListId,
sectionId: input.sectionId,
equipmentIdentifier: input.equipmentIdentifier,
displayName: input.displayName,
sortOrder: input.sortOrder,
protectionType: input.protectionType,
protectionRatedCurrent: input.protectionRatedCurrent,
protectionCharacteristic: input.protectionCharacteristic,
cableType: input.cableType,
cableCrossSection: input.cableCrossSection,
cableLength: input.cableLength,
rcdAssignment: input.rcdAssignment,
terminalDesignation: input.terminalDesignation,
voltage: input.voltage,
controlRequirement: input.controlRequirement,
status: input.status,
isReserve: input.isReserve ?? true,
remark: input.remark,
});
return this.circuitRepository.findById(id);
}
async createCircuitWithDeviceRows(
projectId: string,
circuitListId: string,
input: CreateCircuitWithDeviceRowsInput
) {
const list = await this.circuitListRepository.findById(projectId, circuitListId);
if (!list) {
throw new Error("Circuit list not found in project.");
}
await this.assertSectionInList(input.circuit.sectionId, circuitListId);
await this.assertUniqueEquipmentIdentifier(
circuitListId,
input.circuit.equipmentIdentifier
);
for (const row of input.deviceRows) {
await this.assertValidLinkedProjectDeviceForProject(
list.projectId,
row.linkedProjectDeviceId
);
}
const created = this.getDeviceRowTransactionStore().createCircuitWithDeviceRows({
circuit: {
circuitListId,
...input.circuit,
},
deviceRows: input.deviceRows,
});
const circuit = await this.circuitRepository.findById(created.circuitId);
const deviceRows = await Promise.all(
created.rowIds.map((rowId) => this.deviceRowRepository.findById(rowId))
);
if (!circuit || deviceRows.some((row) => !row)) {
throw new Error("Der angelegte Stromkreis konnte nicht geladen werden.");
}
return {
circuit,
deviceRows,
};
}
async updateCircuit(circuitId: string, input: UpdateCircuitInput) {
const current = await this.circuitRepository.findById(circuitId);
if (!current) {
throw new Error("Invalid circuit id.");
}
const sectionId = input.sectionId ?? current.sectionId;
const equipmentIdentifier = input.equipmentIdentifier ?? current.equipmentIdentifier;
await this.assertSectionInList(sectionId, current.circuitListId);
await this.assertUniqueEquipmentIdentifier(current.circuitListId, equipmentIdentifier, circuitId);
await this.circuitRepository.updateFields(circuitId, input);
return this.circuitRepository.findById(circuitId);
}
async deleteCircuit(circuitId: string) {
const current = await this.circuitRepository.findById(circuitId);
if (!current) {
@@ -214,67 +69,6 @@ export class CircuitWriteService {
await this.circuitRepository.delete(circuitId);
}
async createDeviceRow(circuitId: string, input: CreateCircuitDeviceRowInput) {
const circuit = await this.circuitRepository.findById(circuitId);
if (!circuit) {
throw new Error("Invalid circuit id.");
}
await this.assertValidLinkedProjectDevice(circuitId, input.linkedProjectDeviceId);
const rowId = this.getDeviceRowTransactionStore().createInCircuit({
circuitId,
linkedProjectDeviceId: input.linkedProjectDeviceId,
sortOrder: input.sortOrder,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType,
connectionKind: input.connectionKind,
costGroup: input.costGroup,
category: input.category,
level: input.level,
roomId: input.roomId,
roomNumberSnapshot: input.roomNumberSnapshot,
roomNameSnapshot: input.roomNameSnapshot,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi,
remark: input.remark,
overriddenFields: input.overriddenFields,
});
return this.deviceRowRepository.findById(rowId);
}
async updateDeviceRow(rowId: string, input: UpdateCircuitDeviceRowInput) {
const current = await this.deviceRowRepository.findById(rowId);
if (!current) {
throw new Error("Invalid device row id.");
}
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
current,
input
);
await this.deviceRowRepository.updateFields(rowId, {
...input,
...(overriddenFields !== undefined ? { overriddenFields } : {}),
});
return this.deviceRowRepository.findById(rowId);
}
async deleteDeviceRow(rowId: string) {
const current = await this.deviceRowRepository.findById(rowId);
if (!current) {
throw new Error("Invalid device row id.");
}
const circuit = await this.circuitRepository.findById(current.circuitId);
if (!circuit) {
throw new Error("Invalid circuit id.");
}
this.getDeviceRowTransactionStore().deleteFromCircuit(rowId, circuit.id);
}
async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) {
const row = await this.deviceRowRepository.findById(rowId);
if (!row) {
@@ -19,6 +19,14 @@ import {
assertCircuitUpdateProjectCommand,
circuitUpdateCommandType,
} from "../models/circuit-project-command.model.js";
import {
assertCircuitSectionReorderProjectCommand,
circuitSectionReorderCommandType,
} from "../models/circuit-section-reorder-project-command.model.js";
import {
assertCircuitSectionRenumberProjectCommand,
circuitSectionRenumberCommandType,
} from "../models/circuit-section-renumber-project-command.model.js";
import {
assertCircuitDeleteProjectCommand,
assertCircuitInsertProjectCommand,
@@ -29,10 +37,26 @@ import {
assertSerializedProjectCommand,
type SerializedProjectCommand,
} from "../models/project-command.model.js";
import {
assertProjectDeviceRowSyncProjectCommand,
projectDeviceRowSyncCommandType,
} from "../models/project-device-row-sync-project-command.model.js";
import {
assertProjectDeviceUpdateProjectCommand,
projectDeviceUpdateCommandType,
} from "../models/project-device-project-command.model.js";
import {
assertProjectDeviceDeleteProjectCommand,
assertProjectDeviceInsertProjectCommand,
projectDeviceDeleteCommandType,
projectDeviceInsertCommandType,
} from "../models/project-device-structure-project-command.model.js";
import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js";
import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js";
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
import type {
ExecuteProjectHistoryCommandInput,
@@ -44,6 +68,9 @@ import type {
ProjectHistoryDirection,
ProjectHistoryStore,
} from "../ports/project-history.store.js";
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
import type { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.store.js";
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
interface DispatchProjectCommandInput {
@@ -63,6 +90,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -186,6 +218,48 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case circuitSectionReorderCommandType: {
assertCircuitSectionReorderProjectCommand(input.command);
return this.circuitSectionReorderStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitSectionRenumberCommandType: {
assertCircuitSectionRenumberProjectCommand(input.command);
return this.circuitSectionRenumberStore.execute({
...input,
command: input.command,
}).revision;
}
case projectDeviceRowSyncCommandType: {
assertProjectDeviceRowSyncProjectCommand(input.command);
return this.projectDeviceRowSyncStore.execute({
...input,
command: input.command,
}).revision;
}
case projectDeviceUpdateCommandType: {
assertProjectDeviceUpdateProjectCommand(input.command);
return this.projectDeviceStore.executeUpdate({
...input,
command: input.command,
}).revision;
}
case projectDeviceInsertCommandType: {
assertProjectDeviceInsertProjectCommand(input.command);
return this.projectDeviceStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case projectDeviceDeleteCommandType: {
assertProjectDeviceDeleteProjectCommand(input.command);
return this.projectDeviceStructureStore.execute({
...input,
command: input.command,
}).revision;
}
default:
throw new Error(
`Unsupported project command type: ${input.command.type}.`
@@ -1,6 +1,11 @@
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import type { ProjectDeviceRowSyncStore } from "../ports/project-device-row-sync.store.js";
import {
createProjectDeviceRowSyncProjectCommand,
projectDeviceSyncRowSnapshotFields,
type ProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot,
} from "../models/project-device-row-sync-project-command.model.js";
import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
@@ -20,20 +25,9 @@ type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProj
type SyncDependencies = {
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
deviceRowRepository: Pick<
CircuitDeviceRowRepository,
| "listLinkedByProjectDevice"
| "listLinkStatesByIds"
>;
deviceRowSyncStore: ProjectDeviceRowSyncStore;
deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice">;
};
export interface ProjectDeviceSyncRestoreRow {
rowId: string;
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
overriddenFields: ProjectDeviceSyncField[];
}
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
return projectDevice[field];
}
@@ -56,19 +50,10 @@ function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
export class ProjectDeviceSyncService {
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
private readonly deviceRowSyncStore?: SyncDependencies["deviceRowSyncStore"];
constructor(deps?: Partial<SyncDependencies>) {
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
this.deviceRowSyncStore = deps?.deviceRowSyncStore;
}
private getDeviceRowSyncStore() {
if (!this.deviceRowSyncStore) {
throw new Error("Project-device row synchronization is not configured.");
}
return this.deviceRowSyncStore;
}
async getPreview(projectId: string, projectDeviceId: string) {
@@ -100,12 +85,12 @@ export class ProjectDeviceSyncService {
};
}
async synchronize(
async createSynchronizeCommand(
projectId: string,
projectDeviceId: string,
rowIds: string[],
fields: ProjectDeviceSyncField[]
) {
): Promise<ProjectDeviceRowSyncProjectCommand> {
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
if (!projectDevice) {
throw new Error("Project device not found.");
@@ -113,81 +98,60 @@ export class ProjectDeviceSyncService {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
const selectedFields = [...new Set(fields)];
const undoRows: ProjectDeviceSyncRestoreRow[] = selectedRows.map((row) => ({
rowId: row.id,
values: Object.fromEntries(selectedFields.map((field) => [field, row[field] ?? null])),
overriddenFields: parseOverriddenFields(row.overriddenFields),
}));
const changes = [];
for (const row of selectedRows) {
const next = this.copyRow(row);
const assignments = selectedRows.flatMap((row) => {
const expected = toSyncSnapshot(row);
const target = { ...expected };
for (const field of selectedFields) {
Object.assign(next, { [field]: sourceValue(projectDevice, field) ?? undefined });
Object.assign(target, {
[field]: sourceValue(projectDevice, field) ?? null,
});
}
const remainingOverrides = parseOverriddenFields(row.overriddenFields).filter(
(field) => !selectedFields.includes(field)
);
next.overriddenFields = serializeOverriddenFields(remainingOverrides);
changes.push({ rowId: row.id, input: next });
}
this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes);
return {
preview: await this.getPreview(projectId, projectDeviceId),
undo: { rows: undoRows },
};
}
async disconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
const disconnectedRowIds = selectedRows.map((row) => row.id);
this.getDeviceRowSyncStore().disconnectLinkedRows(
projectDeviceId,
disconnectedRowIds
);
return { disconnectedRowIds, undo: { rowIds: disconnectedRowIds } };
}
async restore(
projectId: string,
projectDeviceId: string,
restoreRows: ProjectDeviceSyncRestoreRow[]
) {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, restoreRows.map((row) => row.rowId));
const restoreById = new Map(restoreRows.map((row) => [row.rowId, row]));
const changes = selectedRows.map((row) => {
const restore = restoreById.get(row.id)!;
const next = this.copyRow(row);
for (const [field, value] of Object.entries(restore.values) as Array<
[ProjectDeviceSyncField, string | number | null]
>) {
Object.assign(next, { [field]: value ?? undefined });
}
next.overriddenFields = serializeOverriddenFields(restore.overriddenFields);
return { rowId: row.id, input: next };
target.overriddenFields =
serializeOverriddenFields(remainingOverrides) ?? null;
return snapshotsEqual(expected, target)
? []
: [{ rowId: row.id, expected, target }];
});
this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes);
return this.getPreview(projectId, projectDeviceId);
return createProjectDeviceRowSyncProjectCommand(
projectDeviceId,
"synchronize",
assignments
);
}
async reconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
async createDisconnectCommand(
projectId: string,
projectDeviceId: string,
rowIds: string[]
): Promise<ProjectDeviceRowSyncProjectCommand> {
const projectDevice = await this.projectDeviceRepository.findById(
projectId,
projectDeviceId
);
if (!projectDevice) {
throw new Error("Project device not found.");
}
const uniqueRowIds = [...new Set(rowIds)];
const linkStates = await this.deviceRowRepository.listLinkStatesByIds(projectId, uniqueRowIds);
if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) {
throw new Error("One or more rows cannot be reconnected.");
}
this.getDeviceRowSyncStore().reconnectRows(projectDeviceId, uniqueRowIds);
return this.getPreview(projectId, projectDeviceId);
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
return createProjectDeviceRowSyncProjectCommand(
projectDeviceId,
"disconnect",
selectedRows.map((row) => {
const expected = toSyncSnapshot(row);
return {
rowId: row.id,
expected,
target: {
...expected,
linkedProjectDeviceId: null,
},
};
})
);
}
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
@@ -200,25 +164,31 @@ export class ProjectDeviceSyncService {
return selectedRows;
}
private copyRow(row: LinkedRow) {
return {
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType ?? undefined,
connectionKind: row.connectionKind ?? undefined,
costGroup: row.costGroup ?? undefined,
category: row.category ?? undefined,
level: row.level ?? undefined,
roomId: row.roomId ?? undefined,
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined,
roomNameSnapshot: row.roomNameSnapshot ?? undefined,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi ?? undefined,
remark: row.remark ?? undefined,
overriddenFields: row.overriddenFields ?? undefined,
};
}
}
function toSyncSnapshot(row: LinkedRow): ProjectDeviceSyncRowSnapshot {
return {
linkedProjectDeviceId: row.linkedProjectDeviceId,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType,
connectionKind: row.connectionKind,
costGroup: row.costGroup,
category: row.category,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
remark: row.remark,
overriddenFields: row.overriddenFields,
};
}
function snapshotsEqual(
left: ProjectDeviceSyncRowSnapshot,
right: ProjectDeviceSyncRowSnapshot
) {
return projectDeviceSyncRowSnapshotFields.every(
(field) => left[field] === right[field]
);
}
File diff suppressed because it is too large Load Diff
+8 -20
View File
@@ -92,6 +92,11 @@ export interface ProjectDeviceDto {
voltageV: number | null;
}
export interface ProjectDeviceCommandResultDto
extends ProjectCommandResultDto {
projectDevice: ProjectDeviceDto;
}
export interface CreateFloorInput {
name: string;
}
@@ -160,24 +165,9 @@ export interface ProjectDeviceSyncPreviewDto {
rows: ProjectDeviceSyncRowDto[];
}
export interface ProjectDeviceSyncRestoreRowDto {
rowId: string;
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
overriddenFields: ProjectDeviceSyncField[];
}
export interface ProjectDeviceSyncResultDto {
export interface ProjectDeviceSyncCommandResultDto
extends ProjectCommandResultDto {
preview: ProjectDeviceSyncPreviewDto;
undo: {
rows: ProjectDeviceSyncRestoreRowDto[];
};
}
export interface ProjectDeviceDisconnectResultDto {
disconnectedRowIds: string[];
undo: {
rowIds: string[];
};
}
export interface CircuitTreeDeviceRowDto {
@@ -250,6 +240,7 @@ export interface CircuitTreeMigrationReportDto {
export interface CircuitTreeResponseDto {
circuitListId: string;
currentRevision: number;
sections: CircuitTreeSectionDto[];
migrationReport?: CircuitTreeMigrationReportDto;
}
@@ -274,8 +265,6 @@ export interface CreateCircuitInputDto {
remark?: string;
}
export type UpdateCircuitInputDto = Partial<CreateCircuitInputDto>;
export interface CreateCircuitDeviceRowInputDto {
linkedProjectDeviceId?: string;
name: string;
@@ -297,5 +286,4 @@ export interface CreateCircuitDeviceRowInputDto {
sortOrder?: number;
}
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
+153 -100
View File
@@ -9,23 +9,30 @@ import type {
GlobalDeviceDto,
ProjectCommandDto,
ProjectCommandResultDto,
ProjectDeviceCommandResultDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
ProjectDeviceDisconnectResultDto,
ProjectDeviceSyncCommandResultDto,
ProjectDeviceSyncPreviewDto,
ProjectDeviceSyncRestoreRowDto,
ProjectDeviceSyncResultDto,
ProjectDto,
RoomDto,
CircuitTreeResponseDto,
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CreateCircuitInputDto,
UpdateCircuitInputDto,
CreateCircuitDeviceRowInputDto,
UpdateCircuitDeviceRowInputDto,
} from "../types";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
import {
createCircuitUpdateProjectCommand,
type CircuitUpdatePatch,
} from "../../domain/models/circuit-project-command.model";
import {
createCircuitDeviceRowUpdateProjectCommand,
type CircuitDeviceRowUpdatePatch,
} from "../../domain/models/circuit-device-row-project-command.model";
import type {
CircuitDeviceRowSnapshot,
} from "../../domain/models/circuit-device-row-structure-project-command.model";
import type {
CircuitSnapshot,
} from "../../domain/models/circuit-structure-project-command.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -154,38 +161,55 @@ export function getCircuitTree(projectId: string, circuitListId: string) {
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
}
export function createCircuit(projectId: string, circuitListId: string, input: CreateCircuitInputDto) {
return request(`/api/projects/${projectId}/circuit-lists/${circuitListId}/circuits`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function createCircuitWithDeviceRows(
export function updateCircuitById(
projectId: string,
circuitListId: string,
input: {
circuit: CreateCircuitInputDto;
deviceRows: CreateCircuitDeviceRowInputDto[];
}
expectedRevision: number,
circuitId: string,
input: CircuitUpdatePatch
) {
return request<{
circuit: CircuitTreeCircuitDto;
deviceRows: CircuitTreeDeviceRowDto[];
}>(
`/api/projects/${projectId}/circuit-lists/${circuitListId}/circuits-with-device-rows`,
{
method: "POST",
body: JSON.stringify(input),
}
return executeProjectCommand(
projectId,
expectedRevision,
createCircuitUpdateProjectCommand(circuitId, input),
"Stromkreisfeld bearbeiten"
);
}
export function updateCircuitById(circuitId: string, input: UpdateCircuitInputDto) {
return request(`/api/circuits/${circuitId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
export function insertCircuitCommand(
projectId: string,
expectedRevision: number,
circuit: CircuitSnapshot,
description: string
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit.insert",
payload: { circuit },
},
description
);
}
export function deleteCircuitCommand(
projectId: string,
expectedRevision: number,
circuitId: string,
expectedCircuitListId: string,
description: string
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit.delete",
payload: { circuitId, expectedCircuitListId },
},
description
);
}
export function deleteCircuitById(circuitId: string) {
@@ -200,24 +224,55 @@ export function getNextCircuitIdentifier(sectionId: string) {
);
}
export function createCircuitDeviceRow(circuitId: string, input: CreateCircuitDeviceRowInputDto) {
return request(`/api/circuits/${circuitId}/device-rows`, {
method: "POST",
body: JSON.stringify(input),
});
export function updateCircuitDeviceRowById(
projectId: string,
expectedRevision: number,
rowId: string,
input: CircuitDeviceRowUpdatePatch
) {
return executeProjectCommand(
projectId,
expectedRevision,
createCircuitDeviceRowUpdateProjectCommand(rowId, input),
"Gerätezeilenfeld bearbeiten"
);
}
export function updateCircuitDeviceRowById(rowId: string, input: UpdateCircuitDeviceRowInputDto) {
return request(`/api/circuit-device-rows/${rowId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
export function insertCircuitDeviceRowCommand(
projectId: string,
expectedRevision: number,
row: CircuitDeviceRowSnapshot,
description: string
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-device-row.insert",
payload: { row },
},
description
);
}
export function deleteCircuitDeviceRowById(rowId: string) {
return request<void>(`/api/circuit-device-rows/${rowId}`, {
method: "DELETE",
});
export function deleteCircuitDeviceRowCommand(
projectId: string,
expectedRevision: number,
rowId: string,
expectedCircuitId: string,
description: string
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-device-row.delete",
payload: { rowId, expectedCircuitId },
},
description
);
}
export function moveCircuitDeviceRowById(
@@ -322,34 +377,58 @@ export function listProjectDevices(projectId: string) {
return request<ProjectDeviceDto[]>(`/api/project-devices/projects/${projectId}`);
}
export function createProjectDevice(projectId: string, input: CreateProjectDeviceInput) {
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}`, {
export function createProjectDevice(
projectId: string,
input: CreateProjectDeviceInput,
expectedRevision: number
) {
return request<ProjectDeviceCommandResultDto>(`/api/project-devices/projects/${projectId}`, {
method: "POST",
body: JSON.stringify(input),
body: JSON.stringify({ ...input, expectedRevision }),
});
}
export function updateProjectDevice(
projectId: string,
projectDeviceId: string,
input: CreateProjectDeviceInput
input: CreateProjectDeviceInput,
expectedRevision: number
) {
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, {
method: "PUT",
body: JSON.stringify(input),
});
return request<ProjectDeviceCommandResultDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}`,
{
method: "PUT",
body: JSON.stringify({ ...input, expectedRevision }),
}
);
}
export function deleteProjectDevice(projectId: string, projectDeviceId: string) {
return request<void>(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, {
method: "DELETE",
});
export function deleteProjectDevice(
projectId: string,
projectDeviceId: string,
expectedRevision: number
) {
return request<ProjectCommandResultDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}`,
{
method: "DELETE",
body: JSON.stringify({ expectedRevision }),
}
);
}
export function copyGlobalDeviceToProject(projectId: string, globalDeviceId: string) {
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`, {
method: "POST",
});
export function copyGlobalDeviceToProject(
projectId: string,
globalDeviceId: string,
expectedRevision: number
) {
return request<ProjectDeviceCommandResultDto>(
`/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`,
{
method: "POST",
body: JSON.stringify({ expectedRevision }),
}
);
}
export function getProjectDeviceSyncPreview(projectId: string, projectDeviceId: string) {
@@ -362,13 +441,14 @@ export function synchronizeProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rowIds: string[],
fields: ProjectDeviceSyncField[]
fields: ProjectDeviceSyncField[],
expectedRevision: number
) {
return request<ProjectDeviceSyncResultDto>(
return request<ProjectDeviceSyncCommandResultDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`,
{
method: "POST",
body: JSON.stringify({ rowIds, fields }),
body: JSON.stringify({ rowIds, fields, expectedRevision }),
}
);
}
@@ -376,41 +456,14 @@ export function synchronizeProjectDeviceRows(
export function disconnectProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rowIds: string[]
rowIds: string[],
expectedRevision: number
) {
return request<ProjectDeviceDisconnectResultDto>(
return request<ProjectDeviceSyncCommandResultDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`,
{
method: "POST",
body: JSON.stringify({ rowIds }),
}
);
}
export function restoreProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rows: ProjectDeviceSyncRestoreRowDto[]
) {
return request<ProjectDeviceSyncPreviewDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/restore`,
{
method: "POST",
body: JSON.stringify({ rows }),
}
);
}
export function reconnectProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rowIds: string[]
) {
return request<ProjectDeviceSyncPreviewDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/reconnect`,
{
method: "POST",
body: JSON.stringify({ rowIds }),
body: JSON.stringify({ rowIds, expectedRevision }),
}
);
}
+74
View File
@@ -1,4 +1,6 @@
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js";
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js";
export type CellKey =
| "equipmentIdentifier"
@@ -189,6 +191,78 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
return parsed;
}
export function buildCircuitEditPatch(
key: CellKey,
draft: string
): CircuitUpdatePatch {
if (key === "protectionSummary" || key === "cableSummary") {
return { remark: draft.trim() || null };
}
if (
key === "protectionRatedCurrent" ||
key === "cableLength" ||
key === "voltage"
) {
return { [key]: parseNumeric(key, draft) ?? null };
}
if (key === "isReserve") {
const normalized = draft.trim().toLowerCase();
return {
isReserve: ["1", "true", "yes", "ja"].includes(normalized),
};
}
if (key === "equipmentIdentifier") {
return { equipmentIdentifier: draft.trim() };
}
return { [key]: draft.trim() || null } as CircuitUpdatePatch;
}
export function buildDeviceRowEditPatch(
key: CellKey,
draft: string
): CircuitDeviceRowUpdatePatch {
if (
key === "quantity" ||
key === "powerPerUnit" ||
key === "simultaneityFactor"
) {
const value = parseNumeric(key, draft);
if (value === undefined) {
throw new Error("Dieses Zahlenfeld darf nicht leer sein.");
}
return { [key]: value };
}
if (key === "cosPhi") {
return { cosPhi: parseNumeric(key, draft) ?? null };
}
if (key === "roomSummary") {
const trimmed = draft.trim();
if (!trimmed) {
return {
roomNumberSnapshot: null,
roomNameSnapshot: null,
};
}
const firstSpace = trimmed.indexOf(" ");
return firstSpace > 0
? {
roomNumberSnapshot: trimmed.slice(0, firstSpace).trim(),
roomNameSnapshot: trimmed.slice(firstSpace + 1).trim() || null,
}
: {
roomNumberSnapshot: trimmed,
roomNameSnapshot: null,
};
}
if (key === "technicalName") {
return { name: draft.trim() };
}
if (key === "displayName") {
return { displayName: draft.trim() };
}
return { [key]: draft.trim() || null } as CircuitDeviceRowUpdatePatch;
}
export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): GridValue {
switch (key) {
case "technicalName": return device.name;
@@ -0,0 +1,73 @@
import type {
CircuitDeviceRowSnapshot,
} from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type {
CircuitSnapshot,
} from "../../domain/models/circuit-structure-project-command.model.js";
import type {
CreateCircuitDeviceRowInputDto,
CreateCircuitInputDto,
} from "../types.js";
export function buildCircuitDeviceRowInsertSnapshot(input: {
id: string;
circuitId: string;
values: CreateCircuitDeviceRowInputDto;
defaultSortOrder: number;
}): CircuitDeviceRowSnapshot {
const { id, circuitId, values, defaultSortOrder } = input;
return {
id,
circuitId,
linkedProjectDeviceId: values.linkedProjectDeviceId ?? null,
legacyConsumerId: null,
sortOrder: values.sortOrder ?? defaultSortOrder,
name: values.name,
displayName: values.displayName,
phaseType: values.phaseType ?? null,
connectionKind: values.connectionKind ?? null,
costGroup: values.costGroup ?? null,
category: values.category ?? null,
level: values.level ?? null,
roomId: values.roomId ?? null,
roomNumberSnapshot: values.roomNumberSnapshot ?? null,
roomNameSnapshot: values.roomNameSnapshot ?? null,
quantity: values.quantity,
powerPerUnit: values.powerPerUnit,
simultaneityFactor: values.simultaneityFactor,
cosPhi: values.cosPhi ?? null,
remark: values.remark ?? null,
overriddenFields: values.overriddenFields ?? null,
};
}
export function buildCircuitInsertSnapshot(input: {
id: string;
circuitListId: string;
values: CreateCircuitInputDto;
deviceRows: CircuitDeviceRowSnapshot[];
}): CircuitSnapshot {
const { id, circuitListId, values, deviceRows } = input;
return {
id,
circuitListId,
sectionId: values.sectionId,
equipmentIdentifier: values.equipmentIdentifier,
displayName: values.displayName ?? null,
sortOrder: values.sortOrder,
protectionType: values.protectionType ?? null,
protectionRatedCurrent: values.protectionRatedCurrent ?? null,
protectionCharacteristic: values.protectionCharacteristic ?? null,
cableType: values.cableType ?? null,
cableCrossSection: values.cableCrossSection ?? null,
cableLength: values.cableLength ?? null,
rcdAssignment: values.rcdAssignment ?? null,
terminalDesignation: values.terminalDesignation ?? null,
voltage: values.voltage ?? null,
controlRequirement: values.controlRequirement ?? null,
status: values.status ?? null,
isReserve: deviceRows.length === 0,
remark: values.remark ?? null,
deviceRows,
};
}
@@ -3,8 +3,13 @@ import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/
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 { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js";
import { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
@@ -16,6 +21,16 @@ export const circuitDeviceRowMoveProjectCommandStore =
new CircuitDeviceRowMoveProjectCommandRepository(db);
export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(db);
export const circuitSectionRenumberProjectCommandStore =
new CircuitSectionRenumberProjectCommandRepository(db);
export const projectDeviceStructureProjectCommandStore =
new ProjectDeviceStructureProjectCommandRepository(db);
export const projectDeviceProjectCommandStore =
new ProjectDeviceProjectCommandRepository(db);
export const projectDeviceRowSyncProjectCommandStore =
new ProjectDeviceRowSyncProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
@@ -23,5 +38,10 @@ export const projectCommandService = new ProjectCommandService(
circuitDeviceRowStructureProjectCommandStore,
circuitDeviceRowMoveProjectCommandStore,
circuitStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore,
circuitSectionRenumberProjectCommandStore,
projectDeviceStructureProjectCommandStore,
projectDeviceProjectCommandStore,
projectDeviceRowSyncProjectCommandStore,
projectHistoryStore
);
@@ -1,7 +1,3 @@
import { db } from "../../db/client.js";
import { ProjectDeviceRowSyncRepository } from "../../db/repositories/project-device-row-sync.repository.js";
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
export const projectDeviceSyncService = new ProjectDeviceSyncService({
deviceRowSyncStore: new ProjectDeviceRowSyncRepository(db),
});
export const projectDeviceSyncService = new ProjectDeviceSyncService();
@@ -1,64 +1,10 @@
import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js";
import {
createCircuitDeviceRowSchema,
moveCircuitDeviceRowsBulkSchema,
moveCircuitDeviceRowSchema,
updateCircuitDeviceRowSchema,
} from "../../shared/validation/circuit.schemas.js";
export async function createCircuitDeviceRow(req: Request, res: Response) {
const { circuitId } = req.params;
if (typeof circuitId !== "string") {
return res.status(400).json({ error: "Invalid circuitId" });
}
const parsed = createCircuitDeviceRowSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const created = await circuitWriteService.createDeviceRow(circuitId, parsed.data);
return res.status(201).json(created);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to create device row." });
}
}
export async function updateCircuitDeviceRow(req: Request, res: Response) {
const { rowId } = req.params;
if (typeof rowId !== "string") {
return res.status(400).json({ error: "Invalid rowId" });
}
const parsed = updateCircuitDeviceRowSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const updated = await circuitWriteService.updateDeviceRow(rowId, parsed.data);
return res.json(updated);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update device row." });
}
}
export async function deleteCircuitDeviceRow(req: Request, res: Response) {
const { rowId } = req.params;
if (typeof rowId !== "string") {
return res.status(400).json({ error: "Invalid rowId" });
}
try {
await circuitWriteService.deleteDeviceRow(rowId);
return res.status(204).send();
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to delete device row." });
}
}
export async function moveCircuitDeviceRow(req: Request, res: Response) {
const { rowId } = req.params;
if (typeof rowId !== "string") {
@@ -3,6 +3,7 @@ import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import { ProjectRepository } from "../../db/repositories/project.repository.js";
import {
calculateCircuitTotalPower,
calculateRowTotalPower,
@@ -13,6 +14,7 @@ const circuitListRepository = new CircuitListRepository();
const circuitSectionRepository = new CircuitSectionRepository();
const circuitRepository = new CircuitRepository();
const circuitDeviceRowRepository = new CircuitDeviceRowRepository();
const projectRepository = new ProjectRepository();
export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
if (!(error instanceof Error)) {
@@ -35,6 +37,10 @@ export async function getCircuitTree(req: Request, res: Response) {
if (!list) {
return res.status(404).json({ error: "Circuit list not found" });
}
const project = await projectRepository.findById(projectId);
if (!project) {
return res.status(404).json({ error: "Project not found" });
}
try {
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
@@ -52,6 +58,7 @@ export async function getCircuitTree(req: Request, res: Response) {
const tree: CircuitTreeResponse = {
circuitListId,
currentRevision: project.currentRevision,
sections: sections.map((section) => ({
id: section.id,
key: section.key,
@@ -123,6 +130,7 @@ export async function getCircuitTree(req: Request, res: Response) {
if (isMissingCircuitTreeSchemaError(error)) {
return res.json({
circuitListId,
currentRevision: project.currentRevision,
sections: [],
warning:
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
@@ -1,76 +1,5 @@
import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js";
import {
createCircuitSchema,
createCircuitWithDeviceRowsSchema,
updateCircuitSchema,
} from "../../shared/validation/circuit.schemas.js";
export async function createCircuit(req: Request, res: Response) {
const { projectId, circuitListId } = req.params;
if (typeof projectId !== "string" || typeof circuitListId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = createCircuitSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const created = await circuitWriteService.createCircuit(projectId, circuitListId, parsed.data);
return res.status(201).json(created);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to create circuit." });
}
}
export async function createCircuitWithDeviceRows(req: Request, res: Response) {
const { projectId, circuitListId } = req.params;
if (typeof projectId !== "string" || typeof circuitListId !== "string") {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const parsed = createCircuitWithDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const created = await circuitWriteService.createCircuitWithDeviceRows(
projectId,
circuitListId,
parsed.data
);
return res.status(201).json(created);
} catch (error) {
return res.status(400).json({
error:
error instanceof Error
? error.message
: "Stromkreis und Gerätezeilen konnten nicht erstellt werden.",
});
}
}
export async function updateCircuit(req: Request, res: Response) {
const { circuitId } = req.params;
if (typeof circuitId !== "string") {
return res.status(400).json({ error: "Invalid circuitId" });
}
const parsed = updateCircuitSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const updated = await circuitWriteService.updateCircuit(circuitId, parsed.data);
return res.json(updated);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update circuit." });
}
}
export async function deleteCircuit(req: Request, res: Response) {
const { circuitId } = req.params;
@@ -1,15 +1,24 @@
import { randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import { projectDeviceSyncService } from "../composition/project-device-sync-service.js";
import { createProjectDeviceUpdateProjectCommand } from "../../domain/models/project-device-project-command.model.js";
import {
createProjectDeviceSchema,
createProjectDeviceDeleteProjectCommand,
createProjectDeviceInsertProjectCommand,
type ProjectDeviceSnapshot,
} from "../../domain/models/project-device-structure-project-command.model.js";
import { projectDeviceSyncService } from "../composition/project-device-sync-service.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import {
createProjectDeviceCommandSchema,
disconnectProjectDeviceRowsSchema,
reconnectProjectDeviceRowsSchema,
restoreProjectDeviceRowsSchema,
projectDeviceStructureCommandSchema,
synchronizeProjectDeviceRowsSchema,
updateProjectDeviceSchema,
updateProjectDeviceCommandSchema,
} from "../../shared/validation/project-device.schemas.js";
import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
const globalDeviceRepository = new GlobalDeviceRepository();
const projectDeviceRepository = new ProjectDeviceRepository();
@@ -27,12 +36,28 @@ export async function createProjectDevice(req: Request, res: Response) {
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const parsed = createProjectDeviceSchema.safeParse(req.body);
const parsed = createProjectDeviceCommandSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const created = await projectDeviceRepository.create(projectId, parsed.data);
return res.status(201).json(created);
const { expectedRevision, ...input } = parsed.data;
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), input);
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: "Projektgerät anlegen",
command: createProjectDeviceInsertProjectCommand(projectDevice),
});
const created = await projectDeviceRepository.findById(projectId, projectDevice.id);
if (!created) {
throw new Error("Created project device could not be loaded.");
}
return res.status(201).json({ ...result, projectDevice: created });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export async function updateProjectDevice(req: Request, res: Response) {
@@ -41,19 +66,32 @@ export async function updateProjectDevice(req: Request, res: Response) {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = updateProjectDeviceSchema.safeParse(req.body);
const parsed = updateProjectDeviceCommandSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const { expectedRevision, ...input } = parsed.data;
await projectDeviceRepository.update(projectId, projectDeviceId, parsed.data);
// Linked rows are synchronized only through the explicit review flow.
// Updating a project device must never silently overwrite local circuit-list values.
const row = await projectDeviceRepository.findById(projectId, projectDeviceId);
if (!row) {
return res.status(404).json({ error: "Project device not found" });
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: "Projektgerät bearbeiten",
command: createProjectDeviceUpdateProjectCommand(
projectDeviceId,
toProjectDeviceValues(input)
),
});
// Linked rows are synchronized only through the explicit review flow.
// Updating a project device must never silently overwrite local circuit-list values.
const projectDevice = await projectDeviceRepository.findById(projectId, projectDeviceId);
if (!projectDevice) {
throw new Error("Updated project device could not be loaded.");
}
return res.json({ ...result, projectDevice });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
return res.json(row);
}
export async function deleteProjectDevice(req: Request, res: Response) {
@@ -62,8 +100,23 @@ export async function deleteProjectDevice(req: Request, res: Response) {
return res.status(400).json({ error: "Invalid parameters" });
}
await projectDeviceRepository.delete(projectId, projectDeviceId);
return res.status(204).send();
const parsed = projectDeviceStructureCommandSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
return res.json(
projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Projektgerät löschen",
command: createProjectDeviceDeleteProjectCommand(projectDeviceId, projectId),
})
);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export async function copyGlobalDeviceToProject(req: Request, res: Response) {
@@ -71,13 +124,17 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
if (typeof projectId !== "string" || typeof globalDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = projectDeviceStructureCommandSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const source = await globalDeviceRepository.findById(globalDeviceId);
if (!source) {
return res.status(404).json({ error: "Global device not found" });
}
const created = await projectDeviceRepository.create(projectId, {
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), {
name: source.name,
displayName: source.displayName,
phaseType: source.phaseCount === 3 ? "three_phase" : "single_phase",
@@ -90,7 +147,50 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
voltageV: source.voltageV ?? undefined,
});
return res.status(201).json(created);
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Globales Gerät ins Projekt übernehmen",
command: createProjectDeviceInsertProjectCommand(projectDevice),
});
const created = await projectDeviceRepository.findById(projectId, projectDevice.id);
if (!created) {
throw new Error("Imported project device could not be loaded.");
}
return res.status(201).json({ ...result, projectDevice: created });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
function toProjectDeviceSnapshot(
projectId: string,
id: string,
input: CreateProjectDeviceInput
): ProjectDeviceSnapshot {
return {
id,
projectId,
...toProjectDeviceValues(input),
};
}
function toProjectDeviceValues(input: CreateProjectDeviceInput) {
return {
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
voltageV: input.voltageV ?? null,
};
}
export async function getProjectDeviceSyncPreview(req: Request, res: Response) {
@@ -116,32 +216,25 @@ export async function synchronizeProjectDeviceRows(req: Request, res: Response)
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const result = await projectDeviceSyncService.synchronize(
const command = await projectDeviceSyncService.createSynchronizeCommand(
projectId,
projectDeviceId,
parsed.data.rowIds,
parsed.data.fields
);
return res.json(result);
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Projektgerät in Stromkreiszeilen synchronisieren",
command,
});
const preview = await projectDeviceSyncService.getPreview(
projectId,
projectDeviceId
);
return res.json({ ...result, preview });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." });
}
}
export async function restoreProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = restoreProjectDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const preview = await projectDeviceSyncService.restore(projectId, projectDeviceId, parsed.data.rows);
return res.json(preview);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to restore rows." });
return respondWithProjectCommandError(error, res);
}
}
@@ -155,26 +248,23 @@ export async function disconnectProjectDeviceRows(req: Request, res: Response) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const result = await projectDeviceSyncService.disconnect(projectId, projectDeviceId, parsed.data.rowIds);
return res.json(result);
const command = await projectDeviceSyncService.createDisconnectCommand(
projectId,
projectDeviceId,
parsed.data.rowIds
);
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Projektgerät-Verknüpfungen trennen",
command,
});
const preview = await projectDeviceSyncService.getPreview(
projectId,
projectDeviceId
);
return res.json({ ...result, preview });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." });
}
}
export async function reconnectProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = reconnectProjectDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const preview = await projectDeviceSyncService.reconnect(projectId, projectDeviceId, parsed.data.rowIds);
return res.json(preview);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to reconnect rows." });
return respondWithProjectCommandError(error, res);
}
}
@@ -1,14 +1,10 @@
import { Router } from "express";
import {
deleteCircuitDeviceRow,
moveCircuitDeviceRowsBulk,
moveCircuitDeviceRow,
updateCircuitDeviceRow,
} from "../controllers/circuit-device-row.controller.js";
export const circuitDeviceRowRouter = Router();
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow);
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
-11
View File
@@ -1,22 +1,11 @@
import { Router } from "express";
import {
createCircuit,
createCircuitWithDeviceRows,
deleteCircuit,
getNextCircuitIdentifier,
updateCircuit,
} from "../controllers/circuit.controller.js";
import { createCircuitDeviceRow } from "../controllers/circuit-device-row.controller.js";
export const circuitRouter = Router();
circuitRouter.post("/projects/:projectId/circuit-lists/:circuitListId/circuits", createCircuit);
circuitRouter.post(
"/projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows",
createCircuitWithDeviceRows
);
circuitRouter.patch("/circuits/:circuitId", updateCircuit);
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
circuitRouter.post("/circuits/:circuitId/device-rows", createCircuitDeviceRow);
@@ -6,8 +6,6 @@ import {
disconnectProjectDeviceRows,
getProjectDeviceSyncPreview,
listProjectDevicesByProject,
reconnectProjectDeviceRows,
restoreProjectDeviceRows,
synchronizeProjectDeviceRows,
updateProjectDevice,
} from "../controllers/project-device.controller.js";
@@ -19,8 +17,6 @@ projectDeviceRouter.post("/projects/:projectId", createProjectDevice);
projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject);
projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/restore", restoreProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/reconnect", reconnectProjectDeviceRows);
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
-60
View File
@@ -1,60 +1,5 @@
import { z } from "zod";
export const createCircuitSchema = z.object({
sectionId: z.string().min(1),
equipmentIdentifier: z.string().min(1),
displayName: z.string().optional(),
sortOrder: z.number(),
protectionType: z.string().optional(),
protectionRatedCurrent: z.number().min(0).optional(),
protectionCharacteristic: z.string().optional(),
cableType: z.string().optional(),
cableCrossSection: z.string().optional(),
cableLength: z.number().min(0).optional(),
rcdAssignment: z.string().optional(),
terminalDesignation: z.string().optional(),
voltage: z.number().positive().optional(),
controlRequirement: z.string().optional(),
status: z.string().optional(),
isReserve: z.boolean().optional(),
remark: z.string().optional(),
});
export const updateCircuitSchema = createCircuitSchema.partial().extend({
sectionId: z.string().min(1).optional(),
equipmentIdentifier: z.string().min(1).optional(),
sortOrder: z.number().optional(),
isReserve: z.boolean().optional(),
});
export const createCircuitDeviceRowSchema = z.object({
linkedProjectDeviceId: z.string().min(1).optional(),
name: z.string().min(1),
displayName: z.string().min(1),
phaseType: z.string().optional(),
connectionKind: z.string().optional(),
costGroup: z.string().optional(),
category: z.string().optional(),
level: z.string().optional(),
roomId: z.string().min(1).optional(),
roomNumberSnapshot: z.string().optional(),
roomNameSnapshot: z.string().optional(),
quantity: z.number().min(0),
powerPerUnit: z.number().min(0),
simultaneityFactor: z.number().min(0),
cosPhi: z.number().positive().optional(),
remark: z.string().optional(),
overriddenFields: z.string().optional(),
sortOrder: z.number().optional(),
});
export const createCircuitWithDeviceRowsSchema = z.object({
circuit: createCircuitSchema,
deviceRows: z.array(createCircuitDeviceRowSchema).min(1),
});
export const updateCircuitDeviceRowSchema = createCircuitDeviceRowSchema.partial();
export const moveCircuitDeviceRowSchema = z
.object({
targetCircuitId: z.string().min(1).optional(),
@@ -97,11 +42,6 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({
.min(1),
});
export type CreateCircuitInput = z.infer<typeof createCircuitSchema>;
export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>;
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
export type CreateCircuitWithDeviceRowsInput = z.infer<typeof createCircuitWithDeviceRowsSchema>;
export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
@@ -1,5 +1,7 @@
import { z } from "zod";
export const expectedProjectRevisionSchema = z.number().int().nonnegative();
const projectCommandEnvelopeSchema = z.object({
schemaVersion: z.number().int().positive(),
type: z.string().trim().min(1),
@@ -7,11 +9,11 @@ const projectCommandEnvelopeSchema = z.object({
});
export const executeProjectCommandSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
expectedRevision: expectedProjectRevisionSchema,
description: z.string().trim().min(1).max(500).optional(),
command: projectCommandEnvelopeSchema,
});
export const executeProjectHistoryCommandSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
expectedRevision: expectedProjectRevisionSchema,
});
+17 -28
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
export const createProjectDeviceSchema = z.object({
name: z.string().min(1),
@@ -19,42 +20,30 @@ export const createProjectDeviceSchema = z.object({
export const updateProjectDeviceSchema = createProjectDeviceSchema;
export const createProjectDeviceCommandSchema =
createProjectDeviceSchema.extend({
expectedRevision: expectedProjectRevisionSchema,
});
export const updateProjectDeviceCommandSchema =
updateProjectDeviceSchema.extend({
expectedRevision: expectedProjectRevisionSchema,
});
export const projectDeviceStructureCommandSchema = z.object({
expectedRevision: expectedProjectRevisionSchema,
});
export const synchronizeProjectDeviceRowsSchema = z.object({
rowIds: z.array(z.string().min(1)).min(1),
fields: z.array(z.enum(projectDeviceSyncFields)).min(1),
expectedRevision: expectedProjectRevisionSchema,
});
export const disconnectProjectDeviceRowsSchema = z.object({
rowIds: z.array(z.string().min(1)).min(1),
expectedRevision: expectedProjectRevisionSchema,
});
const projectDeviceRestoreValuesSchema = z.object({
name: z.string().optional(),
displayName: z.string().optional(),
phaseType: z.enum(["single_phase", "three_phase"]).optional(),
connectionKind: z.string().nullable().optional(),
costGroup: z.string().nullable().optional(),
category: z.string().nullable().optional(),
quantity: z.number().min(0).optional(),
powerPerUnit: z.number().min(0).optional(),
simultaneityFactor: z.number().min(0).max(1).optional(),
cosPhi: z.number().min(0).max(1).nullable().optional(),
remark: z.string().nullable().optional(),
});
export const restoreProjectDeviceRowsSchema = z.object({
rows: z
.array(
z.object({
rowId: z.string().min(1),
values: projectDeviceRestoreValuesSchema,
overriddenFields: z.array(z.enum(projectDeviceSyncFields)),
})
)
.min(1),
});
export const reconnectProjectDeviceRowsSchema = disconnectProjectDeviceRowsSchema;
export type CreateProjectDeviceInput = z.infer<typeof createProjectDeviceSchema>;
export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>;
+36
View File
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
allColumns,
buildCircuitEditPatch,
buildDeviceRowEditPatch,
getBlockSortValue,
getCellKind,
getCircuitValue,
@@ -85,4 +87,38 @@ describe("circuit grid model", () => {
assert.equal(parseNumeric("quantity", ""), undefined);
assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/);
});
it("builds nullable circuit command patches from grid drafts", () => {
assert.deepEqual(buildCircuitEditPatch("voltage", ""), {
voltage: null,
});
assert.deepEqual(
buildCircuitEditPatch("controlRequirement", " DALI "),
{ controlRequirement: "DALI" }
);
assert.deepEqual(buildCircuitEditPatch("isReserve", "ja"), {
isReserve: true,
});
});
it("builds device command patches with explicit clearing semantics", () => {
assert.deepEqual(buildDeviceRowEditPatch("roomSummary", ""), {
roomNumberSnapshot: null,
roomNameSnapshot: null,
});
assert.deepEqual(
buildDeviceRowEditPatch("roomSummary", "1.01 Büro"),
{
roomNumberSnapshot: "1.01",
roomNameSnapshot: "Büro",
}
);
assert.deepEqual(buildDeviceRowEditPatch("technicalName", " Leuchte "), {
name: "Leuchte",
});
assert.throws(
() => buildDeviceRowEditPatch("quantity", ""),
/darf nicht leer sein/
);
});
});
@@ -71,6 +71,8 @@ function createTestFixture(): TestFixture {
displayName: "Licht Bestand",
sortOrder: 10,
protectionRatedCurrent: 10,
voltage: 230,
controlRequirement: "none",
isReserve: 0,
},
{
@@ -111,6 +113,8 @@ describe("circuit project-command repository", () => {
const command = createCircuitUpdateProjectCommand("circuit-1", {
displayName: null,
protectionRatedCurrent: 16,
voltage: 400,
controlRequirement: "DALI",
isReserve: true,
});
@@ -125,6 +129,8 @@ describe("circuit project-command repository", () => {
const circuit = getCircuit(fixture.context);
assert.equal(circuit.displayName, null);
assert.equal(circuit.protectionRatedCurrent, 16);
assert.equal(circuit.voltage, 400);
assert.equal(circuit.controlRequirement, "DALI");
assert.equal(circuit.isReserve, 1);
assert.equal(executed.revision.revisionNumber, 1);
assert.deepEqual(executed.inverse.payload, {
@@ -132,6 +138,8 @@ describe("circuit project-command repository", () => {
changes: [
{ field: "displayName", value: "Licht Bestand" },
{ field: "protectionRatedCurrent", value: 10 },
{ field: "voltage", value: 230 },
{ field: "controlRequirement", value: "none" },
{ field: "isReserve", value: false },
],
});
@@ -0,0 +1,454 @@
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 { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-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 { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
interface TestFixture {
context: DatabaseContext;
sectionId: 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 ownSections = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.orderBy(asc(circuitSections.sortOrder))
.all();
const foreignSection = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, foreignBoard.id))
.get();
assert.ok(ownSections[0]);
assert.ok(ownSections[1]);
assert.ok(foreignSection);
context.db
.insert(circuits)
.values([
{
id: "circuit-1",
circuitListId: board.id,
sectionId: ownSections[0].id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
{
id: "circuit-2",
circuitListId: board.id,
sectionId: ownSections[0].id,
equipmentIdentifier: "-1F2",
sortOrder: 20,
},
{
id: "circuit-3",
circuitListId: board.id,
sectionId: ownSections[0].id,
equipmentIdentifier: "-1F3",
sortOrder: 30,
},
{
id: "circuit-other-section",
circuitListId: board.id,
sectionId: ownSections[1].id,
equipmentIdentifier: "-2F1",
sortOrder: 10,
},
{
id: "circuit-foreign",
circuitListId: foreignBoard.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})
.run();
return {
context,
sectionId: ownSections[0].id,
foreignSectionId: foreignSection.id,
};
}
function getSectionCircuitState(
context: DatabaseContext,
sectionId: string
) {
return context.db
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
sortOrder: circuits.sortOrder,
})
.from(circuits)
.where(eq(circuits.sectionId, sectionId))
.orderBy(asc(circuits.id))
.all();
}
function createSwapCommand(sectionId: string) {
return createCircuitSectionRenumberProjectCommand(sectionId, [
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
{
circuitId: "circuit-3",
expectedEquipmentIdentifier: "-1F3",
targetEquipmentIdentifier: "-1F3",
},
]);
}
describe("circuit section renumber project-command repository", () => {
it("swaps identifiers and supports persisted undo and redo", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionRenumberProjectCommandRepository(
fixture.context.db
);
const command = createSwapCommand(fixture.sectionId);
const renumbered = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
getSectionCircuitState(
fixture.context,
fixture.sectionId
),
[
{
id: "circuit-1",
equipmentIdentifier: "-1F2",
sortOrder: 10,
},
{
id: "circuit-2",
equipmentIdentifier: "-1F1",
sortOrder: 20,
},
{
id: "circuit-3",
equipmentIdentifier: "-1F3",
sortOrder: 30,
},
]
);
assert.equal(
fixture.context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.circuitId,
"circuit-1"
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
renumbered.revision.changeSetId,
command: renumbered.inverse,
});
assert.deepEqual(
getSectionCircuitState(
fixture.context,
fixture.sectionId
).map((circuit) => circuit.equipmentIdentifier),
["-1F1", "-1F2", "-1F3"]
);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId:
renumbered.revision.changeSetId,
command,
});
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: renumbered.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
fixture.context.close();
}
});
it("rejects incomplete, stale and foreign-section assignments", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionRenumberProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionRenumberProjectCommand(
fixture.sectionId,
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
]
),
}),
/every circuit/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionRenumberProjectCommand(
fixture.sectionId,
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F999",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
{
circuitId: "circuit-3",
expectedEquipmentIdentifier: "-1F3",
targetEquipmentIdentifier: "-1F3",
},
]
),
}),
/changed before/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionRenumberProjectCommand(
fixture.foreignSectionId,
[
{
circuitId: "circuit-foreign",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
]
),
}),
/does not belong to project/
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rejects target identifiers used by another section", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionRenumberProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionRenumberProjectCommand(
fixture.sectionId,
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-2F1",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
{
circuitId: "circuit-3",
expectedEquipmentIdentifier: "-1F3",
targetEquipmentIdentifier: "-1F2",
},
]
),
}),
/already exists in circuit list/
);
assert.deepEqual(
getSectionCircuitState(
fixture.context,
fixture.sectionId
).map((circuit) => circuit.equipmentIdentifier),
["-1F1", "-1F2", "-1F3"]
);
} finally {
fixture.context.close();
}
});
it("rolls back temporary and final identifiers for late history failures", () => {
const fixture = createTestDatabase();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_renumber_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced renumber history failure');
END;
`);
const store =
new CircuitSectionRenumberProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createSwapCommand(fixture.sectionId),
}),
/forced renumber history failure/
);
assert.deepEqual(
getSectionCircuitState(
fixture.context,
fixture.sectionId
).map((circuit) => circuit.equipmentIdentifier),
["-1F1", "-1F2", "-1F3"]
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back renumbering for a stale project revision", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionRenumberProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createSwapCommand(fixture.sectionId),
}),
/at revision 0, expected 1/
);
assert.deepEqual(
getSectionCircuitState(
fixture.context,
fixture.sectionId
).map((circuit) => circuit.equipmentIdentifier),
["-1F1", "-1F2", "-1F3"]
);
} finally {
fixture.context.close();
}
});
});
@@ -0,0 +1,391 @@
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 { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-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 { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
interface TestFixture {
context: DatabaseContext;
sectionId: 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(circuits)
.values([
{
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
{
id: "circuit-2",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F2",
sortOrder: 20,
},
{
id: "circuit-3",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F3",
sortOrder: 30,
},
{
id: "circuit-foreign",
circuitListId: foreignBoard.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})
.run();
return {
context,
sectionId: section.id,
foreignSectionId: foreignSection.id,
};
}
function getCircuitState(context: DatabaseContext) {
return context.db
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
sortOrder: circuits.sortOrder,
})
.from(circuits)
.all()
.filter((circuit) => circuit.id !== "circuit-foreign")
.sort((left, right) => left.id.localeCompare(right.id));
}
function createReorderCommand(sectionId: string) {
return createCircuitSectionReorderProjectCommand(sectionId, [
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 30,
},
{
circuitId: "circuit-3",
expectedSortOrder: 30,
targetSortOrder: 10,
},
]);
}
describe("circuit section reorder project-command repository", () => {
it("reorders a complete section and supports persisted undo and redo", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionReorderProjectCommandRepository(
fixture.context.db
);
const command = createReorderCommand(fixture.sectionId);
const reordered = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(getCircuitState(fixture.context), [
{
id: "circuit-1",
equipmentIdentifier: "-1F1",
sortOrder: 20,
},
{
id: "circuit-2",
equipmentIdentifier: "-1F2",
sortOrder: 30,
},
{
id: "circuit-3",
equipmentIdentifier: "-1F3",
sortOrder: 10,
},
]);
assert.equal(
fixture.context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.circuitId,
"circuit-1"
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
reordered.revision.changeSetId,
command: reordered.inverse,
});
assert.deepEqual(
getCircuitState(fixture.context).map(
(circuit) => circuit.sortOrder
),
[10, 20, 30]
);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId:
reordered.revision.changeSetId,
command,
});
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: reordered.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
fixture.context.close();
}
});
it("rejects incomplete, stale and foreign-section reorders", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionReorderProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionReorderProjectCommand(
fixture.sectionId,
[
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
]
),
}),
/every circuit/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionReorderProjectCommand(
fixture.sectionId,
[
{
circuitId: "circuit-1",
expectedSortOrder: 999,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 30,
},
{
circuitId: "circuit-3",
expectedSortOrder: 30,
targetSortOrder: 10,
},
]
),
}),
/changed before/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionReorderProjectCommand(
fixture.foreignSectionId,
[
{
circuitId: "circuit-foreign",
expectedSortOrder: 10,
targetSortOrder: 20,
},
]
),
}),
/does not belong to project/
);
assert.deepEqual(
getCircuitState(fixture.context).map(
(circuit) => circuit.sortOrder
),
[10, 20, 30]
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back all sort positions for late history failures", () => {
const fixture = createTestDatabase();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_reorder_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced reorder history failure');
END;
`);
const store =
new CircuitSectionReorderProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createReorderCommand(fixture.sectionId),
}),
/forced reorder history failure/
);
assert.deepEqual(
getCircuitState(fixture.context).map(
(circuit) => circuit.sortOrder
),
[10, 20, 30]
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back the reorder for a stale project revision", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionReorderProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createReorderCommand(fixture.sectionId),
}),
/at revision 0, expected 1/
);
assert.deepEqual(
getCircuitState(fixture.context).map(
(circuit) => circuit.sortOrder
),
[10, 20, 30]
);
} finally {
fixture.context.close();
}
});
});
+207
View File
@@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildCircuitDeviceRowInsertSnapshot,
buildCircuitInsertSnapshot,
} from "../src/frontend/utils/circuit-structure-command.js";
import {
deleteCircuitCommand,
deleteCircuitDeviceRowCommand,
insertCircuitCommand,
insertCircuitDeviceRowCommand,
} from "../src/frontend/utils/api.js";
describe("circuit structure command adapters", () => {
it("builds a complete row snapshot without legacy identities", () => {
const row = buildCircuitDeviceRowInsertSnapshot({
id: "row-1",
circuitId: "circuit-1",
defaultSortOrder: 20,
values: {
linkedProjectDeviceId: "device-1",
name: "Steckdose",
displayName: "Steckdose Büro",
quantity: 2,
powerPerUnit: 0.4,
simultaneityFactor: 0.8,
},
});
assert.deepEqual(row, {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "device-1",
legacyConsumerId: null,
sortOrder: 20,
name: "Steckdose",
displayName: "Steckdose Büro",
phaseType: null,
connectionKind: null,
costGroup: null,
category: null,
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 2,
powerPerUnit: 0.4,
simultaneityFactor: 0.8,
cosPhi: null,
remark: null,
overriddenFields: null,
});
});
it("derives reserve state from the complete device-row snapshot", () => {
const reserve = buildCircuitInsertSnapshot({
id: "circuit-1",
circuitListId: "list-1",
values: {
sectionId: "section-1",
equipmentIdentifier: "-2F1",
sortOrder: 10,
isReserve: false,
voltage: 230,
controlRequirement: "DALI",
},
deviceRows: [],
});
assert.equal(reserve.isReserve, true);
assert.equal(reserve.displayName, null);
assert.equal(reserve.voltage, 230);
assert.equal(reserve.controlRequirement, "DALI");
const row = buildCircuitDeviceRowInsertSnapshot({
id: "row-1",
circuitId: reserve.id,
defaultSortOrder: 10,
values: {
name: "Gerät",
displayName: "Gerät",
quantity: 1,
powerPerUnit: 0,
simultaneityFactor: 1,
cosPhi: 1,
},
});
const populated = buildCircuitInsertSnapshot({
id: reserve.id,
circuitListId: reserve.circuitListId,
values: {
sectionId: reserve.sectionId,
equipmentIdentifier: reserve.equipmentIdentifier,
sortOrder: reserve.sortOrder,
isReserve: true,
},
deviceRows: [row],
});
assert.equal(populated.isReserve, false);
assert.deepEqual(populated.deviceRows, [row]);
});
it("sends structure envelopes through the project command route", async () => {
const requests: Array<{ url: string; body: Record<string, unknown> }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
});
return new Response(
JSON.stringify({
revision: {
revisionId: "revision-1",
changeSetId: "change-1",
projectId: "project-1",
revisionNumber: requests.length,
createdAtIso: "2026-07-25T00:00:00.000Z",
},
history: {
projectId: "project-1",
currentRevision: requests.length,
undoDepth: requests.length,
redoDepth: 0,
undoChangeSetId: "change-1",
redoChangeSetId: null,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
};
try {
const row = buildCircuitDeviceRowInsertSnapshot({
id: "row-1",
circuitId: "circuit-1",
defaultSortOrder: 10,
values: {
name: "Gerät",
displayName: "Gerät",
quantity: 1,
powerPerUnit: 0,
simultaneityFactor: 1,
},
});
const circuit = buildCircuitInsertSnapshot({
id: "circuit-1",
circuitListId: "list-1",
values: {
sectionId: "section-1",
equipmentIdentifier: "-2F1",
sortOrder: 10,
},
deviceRows: [row],
});
await insertCircuitCommand("project-1", 0, circuit, "insert circuit");
await deleteCircuitCommand(
"project-1",
1,
circuit.id,
circuit.circuitListId,
"delete circuit"
);
await insertCircuitDeviceRowCommand(
"project-1",
2,
row,
"insert row"
);
await deleteCircuitDeviceRowCommand(
"project-1",
3,
row.id,
row.circuitId,
"delete row"
);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(
requests.map((request) => request.url),
Array(4).fill("/api/projects/project-1/commands")
);
assert.deepEqual(
requests.map((request) => {
const command = request.body.command as {
schemaVersion: number;
type: string;
};
return [
request.body.expectedRevision,
command.schemaVersion,
command.type,
];
}),
[
[0, 1, "circuit.insert"],
[1, 1, "circuit.delete"],
[2, 1, "circuit-device-row.insert"],
[3, 1, "circuit-device-row.delete"],
]
);
});
});
-328
View File
@@ -1,304 +1,8 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js";
describe("circuit write service rules", () => {
it("accepts future sizing inputs without calculating sizing suggestions", () => {
const parsed = createCircuitSchema.safeParse({
sectionId: "s1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
voltage: 230,
controlRequirement: "DALI",
});
assert.equal(parsed.success, true);
});
it("passes voltage and control requirements through circuit updates", async () => {
let updatePayload: Record<string, unknown> = {};
const circuit = {
id: "c1",
circuitListId: "l1",
sectionId: "s1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 0,
voltage: 230,
controlRequirement: "none",
};
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1" } as never;
},
} as never,
circuitRepository: {
async findById() {
return circuit as never;
},
async existsByEquipmentIdentifier() {
return false;
},
async updateFields(_id: string, payload: Record<string, unknown>) {
updatePayload = payload;
},
} as never,
});
await service.updateCircuit("c1", { voltage: 400, controlRequirement: "DALI" });
assert.deepEqual(updatePayload, {
voltage: 400,
controlRequirement: "DALI",
});
});
it("rejects duplicate equipment identifiers in same circuit list", async () => {
const service = new CircuitWriteService({
circuitListRepository: {
async findById() {
return { id: "list1", projectId: "p1" } as never;
},
} as never,
circuitSectionRepository: {
async findById() {
return { id: "sec1", circuitListId: "list1" } as never;
},
} as never,
circuitRepository: {
async existsByEquipmentIdentifier() {
return true;
},
} as never,
});
await assert.rejects(
() =>
service.createCircuit("p1", "list1", {
sectionId: "sec1",
equipmentIdentifier: "-2F1",
sortOrder: 10,
}),
/Duplicate equipmentIdentifier/
);
});
it("rejects section and list mismatch", async () => {
const service = new CircuitWriteService({
circuitListRepository: {
async findById() {
return { id: "list1", projectId: "p1" } as never;
},
} as never,
circuitSectionRepository: {
async findById() {
return { id: "sec1", circuitListId: "other" } as never;
},
} as never,
circuitRepository: {
async existsByEquipmentIdentifier() {
return false;
},
} as never,
});
await assert.rejects(
() =>
service.createCircuit("p1", "list1", {
sectionId: "sec1",
equipmentIdentifier: "-2F1",
sortOrder: 10,
}),
/Section does not belong to circuit list/
);
});
it("deleting last device row keeps circuit and sets reserve", async () => {
let transactionalDelete: { rowId: string; circuitId: string } | undefined;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return { id: "r1", circuitId: "c1" } as never;
},
} as never,
deviceRowTransactionStore: {
deleteFromCircuit(rowId: string, circuitId: string) {
transactionalDelete = { rowId, circuitId };
},
} as never,
circuitRepository: {
async findById() {
return {
id: "c1",
sectionId: "s1",
circuitListId: "l1",
equipmentIdentifier: "-2F1",
sortOrder: 10,
isReserve: 0,
} as never;
},
} as never,
});
await service.deleteDeviceRow("r1");
assert.deepEqual(transactionalDelete, { rowId: "r1", circuitId: "c1" });
});
it("creating device row in reserve circuit clears reserve status", async () => {
let transactionalCreate:
| {
circuitId: string;
sortOrder?: number;
name: string;
displayName: string;
}
| undefined;
const service = new CircuitWriteService({
circuitRepository: {
async findById() {
return {
id: "c1",
sectionId: "s1",
circuitListId: "l1",
equipmentIdentifier: "-2F1",
sortOrder: 10,
isReserve: 1,
} as never;
},
} as never,
deviceRowRepository: {
async findById() {
return { id: "row1" } as never;
},
} as never,
deviceRowTransactionStore: {
createInCircuit(input: typeof transactionalCreate) {
transactionalCreate = input;
return "row1";
},
} as never,
circuitListRepository: {} as never,
circuitSectionRepository: {} as never,
projectDeviceRepository: {
async findById() {
return { id: "pd1" } as never;
},
} as never,
});
await service.createDeviceRow("c1", {
name: "Load",
displayName: "Load",
quantity: 1,
powerPerUnit: 1,
simultaneityFactor: 1,
});
assert.deepEqual(transactionalCreate, {
circuitId: "c1",
linkedProjectDeviceId: undefined,
sortOrder: undefined,
name: "Load",
displayName: "Load",
phaseType: undefined,
connectionKind: undefined,
costGroup: undefined,
category: undefined,
level: undefined,
roomId: undefined,
roomNumberSnapshot: undefined,
roomNameSnapshot: undefined,
quantity: 1,
powerPerUnit: 1,
simultaneityFactor: 1,
cosPhi: undefined,
remark: undefined,
overriddenFields: undefined,
});
});
it("creates a circuit and all first device rows through one repository command", async () => {
let transactionalCreate: {
circuit: { circuitListId: string; sectionId: string; equipmentIdentifier: string };
deviceRows: Array<{ linkedProjectDeviceId?: string; name: string }>;
} | undefined;
const service = new CircuitWriteService({
circuitListRepository: {
async findById() {
return { id: "l1", projectId: "p1" } as never;
},
} as never,
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1" } as never;
},
} as never,
circuitRepository: {
async existsByEquipmentIdentifier() {
return false;
},
async findById() {
return { id: "c-new", circuitListId: "l1", sectionId: "s1" } as never;
},
} as never,
projectDeviceRepository: {
async findById(projectId: string, deviceId: string) {
return projectId === "p1" && deviceId === "pd1" ? ({ id: "pd1" } as never) : null;
},
} as never,
deviceRowRepository: {
async findById(rowId: string) {
return { id: rowId, circuitId: "c-new" } as never;
},
} as never,
deviceRowTransactionStore: {
createCircuitWithDeviceRows(input: typeof transactionalCreate) {
transactionalCreate = input;
return { circuitId: "c-new", rowIds: ["r1", "r2"] };
},
} as never,
});
const created = await service.createCircuitWithDeviceRows("p1", "l1", {
circuit: {
sectionId: "s1",
equipmentIdentifier: "-2F1",
displayName: "Steckdosen",
sortOrder: 10,
},
deviceRows: [
{
linkedProjectDeviceId: "pd1",
name: "Socket",
displayName: "Steckdose",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
{
name: "Manual",
displayName: "Manuell",
quantity: 2,
powerPerUnit: 0.1,
simultaneityFactor: 0.8,
},
],
});
assert.equal(transactionalCreate?.circuit.circuitListId, "l1");
assert.equal(transactionalCreate?.circuit.sectionId, "s1");
assert.equal(transactionalCreate?.circuit.equipmentIdentifier, "-2F1");
assert.deepEqual(
transactionalCreate?.deviceRows.map((row) => row.name),
["Socket", "Manual"]
);
assert.deepEqual(
created.deviceRows.map((row) => row?.id),
["r1", "r2"]
);
});
it("renumber uses safe bulk identifier update for swapped identifiers", async () => {
let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
const service = new CircuitWriteService({
@@ -741,36 +445,4 @@ describe("circuit write service rules", () => {
assert.equal(safeCalled, true);
});
it("tracks local edits on linked device rows as overridden fields", async () => {
let updatePayload: Record<string, unknown> = {};
const current = {
id: "r1",
circuitId: "c1",
linkedProjectDeviceId: "pd1",
name: "Luminaire",
displayName: "Local name",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
sortOrder: 10,
overriddenFields: null,
};
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return current as never;
},
async updateFields(_rowId: string, input: Record<string, unknown>) {
updatePayload = input;
},
} as never,
});
await service.updateDeviceRow("r1", { quantity: 2 });
assert.deepEqual(updatePayload, {
quantity: 2,
overriddenFields: "[\"quantity\"]",
});
});
});
+369
View File
@@ -29,6 +29,27 @@ import {
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
} from "../src/domain/models/circuit-structure-project-command.model.js";
import {
assertCircuitSectionReorderProjectCommand,
createCircuitSectionReorderProjectCommand,
} from "../src/domain/models/circuit-section-reorder-project-command.model.js";
import {
assertCircuitSectionRenumberProjectCommand,
createCircuitSectionRenumberProjectCommand,
} from "../src/domain/models/circuit-section-renumber-project-command.model.js";
import {
assertProjectDeviceRowSyncProjectCommand,
createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot,
} from "../src/domain/models/project-device-row-sync-project-command.model.js";
import {
assertProjectDeviceUpdateProjectCommand,
createProjectDeviceUpdateProjectCommand,
} from "../src/domain/models/project-device-project-command.model.js";
import {
createProjectDeviceDeleteProjectCommand,
createProjectDeviceInsertProjectCommand,
} from "../src/domain/models/project-device-structure-project-command.model.js";
describe("serialized project commands", () => {
it("round-trips a versioned command envelope", () => {
@@ -183,6 +204,242 @@ describe("circuit device-row update project commands", () => {
});
});
describe("project-device row sync project commands", () => {
const linkedSnapshot: ProjectDeviceSyncRowSnapshot = {
linkedProjectDeviceId: "project-device-1",
name: "Leuchte",
displayName: "Lokaler Name",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "lighting",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: 0.9,
remark: null,
overriddenFields: '["displayName"]',
};
it("captures complete expected and target snapshots for synchronization", () => {
const command = createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[
{
rowId: "row-1",
expected: linkedSnapshot,
target: {
...linkedSnapshot,
displayName: "Projektgerät",
overriddenFields: null,
},
},
]
);
assert.doesNotThrow(() =>
assertProjectDeviceRowSyncProjectCommand(command)
);
assert.equal(
command.payload.rows[0]?.target.displayName,
"Projektgerät"
);
});
it("permits link-only disconnects and rejects invalid sync transitions", () => {
assert.doesNotThrow(() =>
createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"disconnect",
[
{
rowId: "row-1",
expected: linkedSnapshot,
target: {
...linkedSnapshot,
linkedProjectDeviceId: null,
},
},
]
)
);
assert.throws(
() =>
createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"disconnect",
[
{
rowId: "row-1",
expected: linkedSnapshot,
target: {
...linkedSnapshot,
linkedProjectDeviceId: null,
quantity: 2,
},
},
]
),
/must not change local row values/
);
assert.throws(
() =>
createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[
{
rowId: "row-1",
expected: linkedSnapshot,
target: linkedSnapshot,
},
]
),
/no-op row/
);
});
});
describe("project-device update project commands", () => {
it("creates typed canonical field changes with null clearing", () => {
const command = createProjectDeviceUpdateProjectCommand(
"project-device-1",
{
displayName: "Flurbeleuchtung",
cosPhi: null,
voltageV: 230,
}
);
assert.doesNotThrow(() =>
assertProjectDeviceUpdateProjectCommand(command)
);
assert.deepEqual(command.payload.changes, [
{ field: "displayName", value: "Flurbeleuchtung" },
{ field: "cosPhi", value: null },
{ field: "voltageV", value: 230 },
]);
});
it("rejects empty, duplicate and invalid canonical values", () => {
assert.throws(
() =>
createProjectDeviceUpdateProjectCommand(
"project-device-1",
{}
),
/at least one change/
);
assert.throws(
() =>
assertProjectDeviceUpdateProjectCommand({
schemaVersion: 1,
type: "project-device.update",
payload: {
projectDeviceId: "project-device-1",
changes: [
{ field: "quantity", value: 1 },
{ field: "quantity", value: 2 },
],
},
}),
/duplicate field/
);
assert.throws(
() =>
createProjectDeviceUpdateProjectCommand(
"project-device-1",
{ simultaneityFactor: 1.1 }
),
/outside its allowed range/
);
});
});
describe("project-device structure project commands", () => {
const projectDevice = {
id: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase" as const,
connectionKind: "fixed",
costGroup: "440",
category: "lighting",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
voltageV: 230,
};
const disconnectedRow = {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 10,
name: "Luminaire",
displayName: "Local lighting",
phaseType: "single_phase",
connectionKind: "fixed",
costGroup: "440",
category: "lighting",
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
overriddenFields: '["displayName"]',
};
it("captures complete device and disconnected-row restore snapshots", () => {
const insert = createProjectDeviceInsertProjectCommand(
projectDevice,
[disconnectedRow]
);
const remove = createProjectDeviceDeleteProjectCommand(
projectDevice.id,
projectDevice.projectId
);
assert.deepEqual(insert.payload, {
projectDevice,
linkedRows: [disconnectedRow],
});
assert.deepEqual(remove.payload, {
projectDeviceId: "project-device-1",
expectedProjectId: "project-1",
});
});
it("rejects invalid device values and rows that are already linked", () => {
assert.throws(
() =>
createProjectDeviceInsertProjectCommand({
...projectDevice,
quantity: -1,
}),
/outside its allowed range/
);
assert.throws(
() =>
createProjectDeviceInsertProjectCommand(projectDevice, [
{
...disconnectedRow,
linkedProjectDeviceId: "project-device-1",
},
]),
/must currently be disconnected/
);
});
});
describe("circuit device-row structure project commands", () => {
const row = {
id: "row-1",
@@ -519,3 +776,115 @@ describe("circuit structure project commands", () => {
);
});
});
describe("circuit section reorder project commands", () => {
it("captures every expected and target sort position", () => {
const command = createCircuitSectionReorderProjectCommand(
"section-1",
[
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
]
);
assert.equal(command.payload.assignments.length, 2);
assert.doesNotThrow(() =>
assertCircuitSectionReorderProjectCommand(command)
);
});
it("rejects empty, duplicate and complete no-op assignments", () => {
assert.throws(
() => createCircuitSectionReorderProjectCommand("", []),
/sectionId/
);
assert.throws(
() =>
createCircuitSectionReorderProjectCommand("section-1", [
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-1",
expectedSortOrder: 20,
targetSortOrder: 10,
},
]),
/duplicate circuit ids/
);
assert.throws(
() =>
createCircuitSectionReorderProjectCommand("section-1", [
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 10,
},
]),
/change at least one position/
);
});
});
describe("circuit section renumber project commands", () => {
it("captures every expected and target equipment identifier", () => {
const command = createCircuitSectionRenumberProjectCommand(
"section-1",
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
]
);
assert.equal(command.payload.assignments.length, 2);
assert.doesNotThrow(() =>
assertCircuitSectionRenumberProjectCommand(command)
);
});
it("rejects duplicate and complete no-op assignments", () => {
assert.throws(
() =>
createCircuitSectionRenumberProjectCommand("section-1", [
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F2",
},
]),
/duplicate target identifiers/
);
assert.throws(
() =>
createCircuitSectionRenumberProjectCommand("section-1", [
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F1",
},
]),
/change at least one identifier/
);
});
});
+388
View File
@@ -11,13 +11,19 @@ import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories
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 { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-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 { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
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 { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
@@ -29,7 +35,12 @@ import {
} 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 { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js";
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext {
@@ -84,6 +95,11 @@ function createService(context: DatabaseContext) {
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectDeviceStructureProjectCommandRepository(context.db),
new ProjectDeviceProjectCommandRepository(context.db),
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db)
);
}
@@ -171,6 +187,199 @@ describe("project command service", () => {
}
});
it("dispatches project-device row synchronization and its inverse", () => {
const context = createTestDatabase();
try {
context.db
.insert(projectDevices)
.values({
id: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
})
.run();
context.db
.update(circuitDeviceRows)
.set({
linkedProjectDeviceId: "project-device-1",
overriddenFields: '["displayName"]',
})
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
const expected = {
linkedProjectDeviceId: "project-device-1",
name: "Leuchte",
displayName: "Leuchte",
phaseType: null,
connectionKind: null,
costGroup: null,
category: null,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
overriddenFields: '["displayName"]',
};
const service = createService(context);
const synchronized = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[
{
rowId: "row-1",
expected,
target: {
...expected,
name: "Luminaire",
displayName: "Office lighting",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
overriddenFields: null,
},
},
]
),
});
assert.equal(synchronized.history.undoDepth, 1);
assert.equal(getRowQuantity(context), 4);
const undone = createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(undone.history.redoDepth, 1);
assert.equal(getRowQuantity(context), 1);
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.overriddenFields,
'["displayName"]'
);
} finally {
context.close();
}
});
it("dispatches project-device updates and their persisted inverse", () => {
const context = createTestDatabase();
try {
context.db
.insert(projectDevices)
.values({
id: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
})
.run();
const service = createService(context);
const updated = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createProjectDeviceUpdateProjectCommand(
"project-device-1",
{
displayName: "Flurbeleuchtung",
powerPerUnit: 0.05,
}
),
});
assert.equal(updated.history.undoDepth, 1);
assert.equal(
context.db
.select()
.from(projectDevices)
.where(eq(projectDevices.id, "project-device-1"))
.get()?.displayName,
"Flurbeleuchtung"
);
const undone = createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(undone.history.redoDepth, 1);
assert.equal(
context.db
.select()
.from(projectDevices)
.where(eq(projectDevices.id, "project-device-1"))
.get()?.displayName,
"Office lighting"
);
} finally {
context.close();
}
});
it("dispatches project-device insertion and deletion", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const inserted = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createProjectDeviceInsertProjectCommand({
id: "project-device-new",
projectId: "project-1",
name: "Socket",
displayName: "Office socket",
phaseType: "single_phase",
connectionKind: null,
costGroup: "440",
category: "socket",
quantity: 2,
powerPerUnit: 0.2,
simultaneityFactor: 0.5,
cosPhi: null,
remark: null,
voltageV: 230,
}),
});
assert.equal(inserted.history.undoDepth, 1);
assert.equal(
context.db
.select()
.from(projectDevices)
.where(eq(projectDevices.id, "project-device-new"))
.get()?.displayName,
"Office socket"
);
const undone = createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(undone.history.redoDepth, 1);
assert.equal(
context.db
.select()
.from(projectDevices)
.where(eq(projectDevices.id, "project-device-new"))
.get(),
undefined
);
} finally {
context.close();
}
});
it("rejects stale undo and preserves the domain value and stack", () => {
const context = createTestDatabase();
try {
@@ -475,6 +684,185 @@ describe("project command service", () => {
}
});
it("dispatches complete section reorders without changing identifiers", () => {
const context = createTestDatabase();
try {
const firstCircuit = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(firstCircuit);
context.db
.insert(circuits)
.values({
id: "circuit-2",
circuitListId: firstCircuit.circuitListId,
sectionId: firstCircuit.sectionId,
equipmentIdentifier: "-1F2",
sortOrder: 20,
isReserve: 1,
})
.run();
const reordered = createService(context).executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitSectionReorderProjectCommand(
firstCircuit.sectionId,
[
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
]
),
});
assert.equal(reordered.history.undoDepth, 1);
assert.deepEqual(
context.db
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
sortOrder: circuits.sortOrder,
})
.from(circuits)
.all()
.sort((left, right) => left.id.localeCompare(right.id)),
[
{
id: "circuit-1",
equipmentIdentifier: "-1F1",
sortOrder: 20,
},
{
id: "circuit-2",
equipmentIdentifier: "-1F2",
sortOrder: 10,
},
]
);
createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.deepEqual(
context.db
.select({ id: circuits.id, sortOrder: circuits.sortOrder })
.from(circuits)
.all()
.sort((left, right) => left.id.localeCompare(right.id)),
[
{ id: "circuit-1", sortOrder: 10 },
{ id: "circuit-2", sortOrder: 20 },
]
);
} finally {
context.close();
}
});
it("dispatches explicit section renumbering and its inverse", () => {
const context = createTestDatabase();
try {
const firstCircuit = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(firstCircuit);
context.db
.insert(circuits)
.values({
id: "circuit-2",
circuitListId: firstCircuit.circuitListId,
sectionId: firstCircuit.sectionId,
equipmentIdentifier: "-1F2",
sortOrder: 20,
isReserve: 1,
})
.run();
const renumbered = createService(context).executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitSectionRenumberProjectCommand(
firstCircuit.sectionId,
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
]
),
});
assert.equal(renumbered.history.undoDepth, 1);
assert.deepEqual(
context.db
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
sortOrder: circuits.sortOrder,
})
.from(circuits)
.all()
.sort((left, right) => left.id.localeCompare(right.id)),
[
{
id: "circuit-1",
equipmentIdentifier: "-1F2",
sortOrder: 10,
},
{
id: "circuit-2",
equipmentIdentifier: "-1F1",
sortOrder: 20,
},
]
);
createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.deepEqual(
context.db
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.all()
.sort((left, right) => left.id.localeCompare(right.id)),
[
{
id: "circuit-1",
equipmentIdentifier: "-1F1",
},
{
id: "circuit-2",
equipmentIdentifier: "-1F2",
},
]
);
} finally {
context.close();
}
});
it("rejects unavailable history directions without writing a revision", () => {
const context = createTestDatabase();
try {
@@ -0,0 +1,292 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Other project" },
])
.run();
context.db
.insert(projectDevices)
.values([
{
id: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
connectionKind: "fixed",
costGroup: "440",
category: "lighting",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
voltageV: 230,
},
{
id: "project-device-foreign",
projectId: "project-2",
name: "Foreign device",
displayName: "Foreign device",
phaseType: "three_phase",
quantity: 1,
powerPerUnit: 1,
simultaneityFactor: 1,
},
])
.run();
return context;
}
function getProjectDevice(
context: DatabaseContext,
projectDeviceId = "project-device-1"
) {
const device = context.db
.select()
.from(projectDevices)
.where(eq(projectDevices.id, projectDeviceId))
.get();
assert.ok(device);
return device;
}
describe("project-device project-command repository", () => {
it("updates canonical fields and supports persisted undo and redo", () => {
const context = createTestDatabase();
try {
const store = new ProjectDeviceProjectCommandRepository(
context.db
);
const command = createProjectDeviceUpdateProjectCommand(
"project-device-1",
{
displayName: "Flurbeleuchtung",
phaseType: "three_phase",
connectionKind: null,
powerPerUnit: 0.05,
cosPhi: null,
voltageV: 400,
}
);
const updated = store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
{
displayName: getProjectDevice(context).displayName,
phaseType: getProjectDevice(context).phaseType,
connectionKind: getProjectDevice(context).connectionKind,
powerPerUnit: getProjectDevice(context).powerPerUnit,
cosPhi: getProjectDevice(context).cosPhi,
voltageV: getProjectDevice(context).voltageV,
},
{
displayName: "Flurbeleuchtung",
phaseType: "three_phase",
connectionKind: null,
powerPerUnit: 0.05,
cosPhi: null,
voltageV: 400,
}
);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: updated.revision.changeSetId,
command: updated.inverse,
});
assert.deepEqual(
{
displayName: getProjectDevice(context).displayName,
phaseType: getProjectDevice(context).phaseType,
connectionKind: getProjectDevice(context).connectionKind,
powerPerUnit: getProjectDevice(context).powerPerUnit,
cosPhi: getProjectDevice(context).cosPhi,
voltageV: getProjectDevice(context).voltageV,
},
{
displayName: "Office lighting",
phaseType: "single_phase",
connectionKind: "fixed",
powerPerUnit: 0.04,
cosPhi: 0.95,
voltageV: 230,
}
);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: updated.revision.changeSetId,
command,
});
assert.deepEqual(
new ProjectHistoryRepository(context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: updated.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
context.close();
}
});
it("rejects project devices from another project", () => {
const context = createTestDatabase();
try {
const store = new ProjectDeviceProjectCommandRepository(
context.db
);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceUpdateProjectCommand(
"project-device-foreign",
{ displayName: "Not allowed" }
),
}),
/does not belong to project/
);
assert.equal(
getProjectDevice(
context,
"project-device-foreign"
).displayName,
"Foreign device"
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
it("rolls back the update for a stale project revision", () => {
const context = createTestDatabase();
try {
const store = new ProjectDeviceProjectCommandRepository(
context.db
);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceUpdateProjectCommand(
"project-device-1",
{ displayName: "First change" }
),
});
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceUpdateProjectCommand(
"project-device-1",
{ displayName: "Stale change" }
),
}),
(error) =>
error instanceof ProjectRevisionConflictError &&
error.actualRevision === 1
);
assert.equal(
getProjectDevice(context).displayName,
"First change"
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
1
);
} finally {
context.close();
}
});
it("rolls back the update after a late history failure", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_project_device_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced project-device history failure');
END;
`);
const store = new ProjectDeviceProjectCommandRepository(
context.db
);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceUpdateProjectCommand(
"project-device-1",
{
displayName: "Must roll back",
quantity: 9,
}
),
}),
/forced project-device history failure/
);
assert.equal(
getProjectDevice(context).displayName,
"Office lighting"
);
assert.equal(getProjectDevice(context).quantity, 4);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
});
@@ -0,0 +1,507 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { asc, eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import {
createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot,
} from "../src/domain/models/project-device-row-sync-project-command.model.js";
interface TestFixture {
context: DatabaseContext;
}
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: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
},
{
id: "project-device-foreign",
projectId: "project-2",
name: "Foreign device",
displayName: "Foreign device",
phaseType: "single_phase",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
])
.run();
context.db
.insert(circuits)
.values([
{
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
{
id: "circuit-foreign",
circuitListId: foreignBoard.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 10,
name: "Old luminaire",
displayName: "Local row 1",
phaseType: "single_phase",
category: "lighting",
quantity: 1,
powerPerUnit: 0.03,
simultaneityFactor: 1,
overriddenFields: '["displayName","quantity"]',
},
{
id: "row-2",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 20,
name: "Old luminaire",
displayName: "Local row 2",
phaseType: "single_phase",
category: "lighting",
quantity: 2,
powerPerUnit: 0.03,
simultaneityFactor: 1,
overriddenFields: '["displayName"]',
},
{
id: "row-foreign",
circuitId: "circuit-foreign",
linkedProjectDeviceId: "project-device-foreign",
sortOrder: 10,
name: "Foreign device",
displayName: "Foreign device",
phaseType: "single_phase",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
])
.run();
return { context };
}
function getRow(context: DatabaseContext, rowId: string) {
const row = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, rowId))
.get();
assert.ok(row);
return row;
}
function snapshot(
context: DatabaseContext,
rowId: string
): ProjectDeviceSyncRowSnapshot {
const row = getRow(context, rowId);
return {
linkedProjectDeviceId: row.linkedProjectDeviceId,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType,
connectionKind: row.connectionKind,
costGroup: row.costGroup,
category: row.category,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
remark: row.remark,
overriddenFields: row.overriddenFields,
};
}
function syncCommand(context: DatabaseContext) {
return createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
["row-1", "row-2"].map((rowId) => {
const expected = snapshot(context, rowId);
return {
rowId,
expected,
target: {
...expected,
name: "Luminaire",
displayName: "Office lighting",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
overriddenFields: null,
},
};
})
);
}
function listOwnRows(context: DatabaseContext) {
return context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, "circuit-1"))
.orderBy(asc(circuitDeviceRows.id))
.all();
}
describe("project-device row sync project-command repository", () => {
it("synchronizes multiple rows and supports persisted undo and redo", () => {
const fixture = createTestDatabase();
try {
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const command = syncCommand(fixture.context);
const synchronized = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.displayName,
row.quantity,
row.overriddenFields,
row.sortOrder,
]),
[
["Office lighting", 4, null, 10],
["Office lighting", 4, null, 20],
]
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
synchronized.revision.changeSetId,
command: synchronized.inverse,
});
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.displayName,
row.quantity,
row.overriddenFields,
]),
[
["Local row 1", 1, '["displayName","quantity"]'],
["Local row 2", 2, '["displayName"]'],
]
);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId:
synchronized.revision.changeSetId,
command,
});
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: synchronized.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
fixture.context.close();
}
});
it("disconnects and reconnects rows without changing local values", () => {
const fixture = createTestDatabase();
try {
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const before = listOwnRows(fixture.context);
const command = createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"disconnect",
["row-1", "row-2"].map((rowId) => {
const expected = snapshot(fixture.context, rowId);
return {
rowId,
expected,
target: {
...expected,
linkedProjectDeviceId: null,
},
};
})
);
const disconnected = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.linkedProjectDeviceId,
row.displayName,
row.quantity,
]),
before.map((row) => [null, row.displayName, row.quantity])
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
disconnected.revision.changeSetId,
command: disconnected.inverse,
});
assert.deepEqual(
listOwnRows(fixture.context).map(
(row) => row.linkedProjectDeviceId
),
["project-device-1", "project-device-1"]
);
} finally {
fixture.context.close();
}
});
it("rejects stale snapshots and cross-project device or row access", () => {
const fixture = createTestDatabase();
try {
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const staleCommand = syncCommand(fixture.context);
fixture.context.db
.update(circuitDeviceRows)
.set({ displayName: "Changed elsewhere" })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: staleCommand,
}),
/changed before sync execution/
);
const expected = snapshot(fixture.context, "row-foreign");
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-foreign",
"disconnect",
[
{
rowId: "row-foreign",
expected,
target: {
...expected,
linkedProjectDeviceId: null,
},
},
]
),
}),
/does not belong to project/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"disconnect",
[
{
rowId: "row-foreign",
expected: {
...expected,
linkedProjectDeviceId: "project-device-1",
},
target: {
...expected,
linkedProjectDeviceId: null,
},
},
]
),
}),
/rows do not belong to project/
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back every row and revision after a late history failure", () => {
const fixture = createTestDatabase();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_sync_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced sync history failure');
END;
`);
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: syncCommand(fixture.context),
}),
/forced sync history failure/
);
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.displayName,
row.quantity,
]),
[
["Local row 1", 1],
["Local row 2", 2],
]
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back synchronized rows for a stale project revision", () => {
const fixture = createTestDatabase();
try {
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: syncCommand(fixture.context),
}),
/at revision 0, expected 1/
);
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.displayName,
row.quantity,
]),
[
["Local row 1", 1],
["Local row 2", 2],
]
);
} finally {
fixture.context.close();
}
});
});
@@ -1,269 +0,0 @@
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 { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectDeviceRowSyncRepository } from "../src/db/repositories/project-device-row-sync.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 { 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();
const board = new DistributionBoardRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const [section] = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.limit(1)
.all();
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Beleuchtung",
sortOrder: 10,
isReserve: 0,
})
.run();
context.db
.insert(projectDevices)
.values({
id: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
installedPowerPerUnitKw: 0.04,
demandFactor: 0.8,
})
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 10,
name: "Old luminaire",
displayName: "Local row 1",
quantity: 1,
powerPerUnit: 0.03,
simultaneityFactor: 1,
},
{
id: "row-2",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 20,
name: "Old luminaire",
displayName: "Local row 2",
quantity: 2,
powerPerUnit: 0.03,
simultaneityFactor: 1,
},
])
.run();
return context;
}
function synchronizedValues(displayName: string) {
return {
linkedProjectDeviceId: "project-device-1",
name: "Luminaire",
displayName,
phaseType: "single_phase",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
};
}
function listRows(context: DatabaseContext) {
return context.db
.select()
.from(circuitDeviceRows)
.all()
.sort((left, right) => left.id.localeCompare(right.id));
}
describe("project-device row sync repository", () => {
it("commits synchronized values for all selected linked rows", () => {
const context = createTestDatabase();
try {
const repository = new ProjectDeviceRowSyncRepository(context.db);
repository.updateLinkedRows("project-device-1", [
{ rowId: "row-1", input: synchronizedValues("Synced row 1") },
{ rowId: "row-2", input: synchronizedValues("Synced row 2") },
]);
assert.deepEqual(
listRows(context).map((row) => [
row.displayName,
row.quantity,
row.powerPerUnit,
row.simultaneityFactor,
]),
[
["Synced row 1", 4, 0.04, 0.8],
["Synced row 2", 4, 0.04, 0.8],
]
);
} finally {
context.close();
}
});
it("rolls back earlier synchronized values when a later row update fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_second_sync_update
BEFORE UPDATE ON circuit_device_rows
WHEN OLD.id = 'row-2' AND NEW.display_name = 'Synced'
BEGIN
SELECT RAISE(ABORT, 'forced sync failure');
END;
`);
const repository = new ProjectDeviceRowSyncRepository(context.db);
assert.throws(
() =>
repository.updateLinkedRows("project-device-1", [
{ rowId: "row-1", input: synchronizedValues("Synced") },
{ rowId: "row-2", input: synchronizedValues("Synced") },
]),
/forced sync failure/
);
assert.deepEqual(
listRows(context).map((row) => [row.displayName, row.quantity]),
[
["Local row 1", 1],
["Local row 2", 2],
]
);
} finally {
context.close();
}
});
it("disconnects all selected rows without changing local values", () => {
const context = createTestDatabase();
try {
const repository = new ProjectDeviceRowSyncRepository(context.db);
repository.disconnectLinkedRows("project-device-1", ["row-1", "row-2"]);
assert.deepEqual(
listRows(context).map((row) => [
row.linkedProjectDeviceId,
row.displayName,
row.quantity,
]),
[
[null, "Local row 1", 1],
[null, "Local row 2", 2],
]
);
} finally {
context.close();
}
});
it("rolls back earlier disconnects when a later row update fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_second_disconnect
BEFORE UPDATE ON circuit_device_rows
WHEN OLD.id = 'row-2' AND NEW.linked_project_device_id IS NULL
BEGIN
SELECT RAISE(ABORT, 'forced disconnect failure');
END;
`);
const repository = new ProjectDeviceRowSyncRepository(context.db);
assert.throws(
() =>
repository.disconnectLinkedRows("project-device-1", ["row-1", "row-2"]),
/forced disconnect failure/
);
assert.deepEqual(
listRows(context).map((row) => row.linkedProjectDeviceId),
["project-device-1", "project-device-1"]
);
} finally {
context.close();
}
});
it("reconnects all selected disconnected rows", () => {
const context = createTestDatabase();
try {
context.db.update(circuitDeviceRows).set({ linkedProjectDeviceId: null }).run();
const repository = new ProjectDeviceRowSyncRepository(context.db);
repository.reconnectRows("project-device-1", ["row-1", "row-2"]);
assert.deepEqual(
listRows(context).map((row) => row.linkedProjectDeviceId),
["project-device-1", "project-device-1"]
);
} finally {
context.close();
}
});
it("rolls back earlier reconnects when a later row update fails", () => {
const context = createTestDatabase();
try {
context.db.update(circuitDeviceRows).set({ linkedProjectDeviceId: null }).run();
context.sqlite.exec(`
CREATE TRIGGER fail_second_reconnect
BEFORE UPDATE ON circuit_device_rows
WHEN OLD.id = 'row-2' AND NEW.linked_project_device_id = 'project-device-1'
BEGIN
SELECT RAISE(ABORT, 'forced reconnect failure');
END;
`);
const repository = new ProjectDeviceRowSyncRepository(context.db);
assert.throws(
() => repository.reconnectRows("project-device-1", ["row-1", "row-2"]),
/forced reconnect failure/
);
assert.deepEqual(
listRows(context).map((row) => row.linkedProjectDeviceId),
[null, null]
);
} finally {
context.close();
}
});
});
+82 -1
View File
@@ -1,6 +1,13 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createProjectDeviceSchema } from "../src/shared/validation/project-device.schemas.js";
import {
createProjectDeviceCommandSchema,
createProjectDeviceSchema,
disconnectProjectDeviceRowsSchema,
projectDeviceStructureCommandSchema,
synchronizeProjectDeviceRowsSchema,
updateProjectDeviceCommandSchema,
} from "../src/shared/validation/project-device.schemas.js";
describe("project device circuit-first schema", () => {
it("accepts circuit device fields", () => {
@@ -33,4 +40,78 @@ describe("project device circuit-first schema", () => {
assert.equal(result.success, false);
});
it("requires a project revision for create and update commands", () => {
const device = {
name: "E-Line Pro",
displayName: "Bürobeleuchtung",
phaseType: "single_phase" as const,
quantity: 6,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
};
assert.equal(createProjectDeviceCommandSchema.safeParse(device).success, false);
assert.equal(updateProjectDeviceCommandSchema.safeParse(device).success, false);
assert.equal(
createProjectDeviceCommandSchema.safeParse({
...device,
expectedRevision: 4,
}).success,
true
);
assert.equal(
updateProjectDeviceCommandSchema.safeParse({
...device,
expectedRevision: 4,
}).success,
true
);
});
it("accepts only non-negative integer revisions for structure commands", () => {
assert.equal(
projectDeviceStructureCommandSchema.safeParse({
expectedRevision: 0,
}).success,
true
);
assert.equal(
projectDeviceStructureCommandSchema.safeParse({
expectedRevision: -1,
}).success,
false
);
assert.equal(
projectDeviceStructureCommandSchema.safeParse({
expectedRevision: 1.5,
}).success,
false
);
});
it("requires the current revision for synchronization writes", () => {
assert.equal(
synchronizeProjectDeviceRowsSchema.safeParse({
rowIds: ["row-1"],
fields: ["quantity"],
}).success,
false
);
assert.equal(
synchronizeProjectDeviceRowsSchema.safeParse({
rowIds: ["row-1"],
fields: ["quantity"],
expectedRevision: 7,
}).success,
true
);
assert.equal(
disconnectProjectDeviceRowsSchema.safeParse({
rowIds: ["row-1"],
expectedRevision: 7,
}).success,
true
);
});
});
@@ -0,0 +1,503 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { asc, eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import {
createProjectDeviceDeleteProjectCommand,
createProjectDeviceInsertProjectCommand,
type ProjectDeviceSnapshot,
} from "../src/domain/models/project-device-structure-project-command.model.js";
function newProjectDeviceSnapshot(): ProjectDeviceSnapshot {
return {
id: "project-device-new",
projectId: "project-1",
name: "Socket",
displayName: "Office socket",
phaseType: "single_phase",
connectionKind: null,
costGroup: "440",
category: "socket",
quantity: 2,
powerPerUnit: 0.2,
simultaneityFactor: 0.5,
cosPhi: null,
remark: null,
voltageV: 230,
};
}
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Other project" },
])
.run();
const boards = new DistributionBoardRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections(
"project-1",
"UV-01"
);
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
"project-2",
"UV-02"
);
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
const foreignSection = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, foreignBoard.id))
.get();
assert.ok(section);
assert.ok(foreignSection);
context.db
.insert(projectDevices)
.values([
{
id: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
connectionKind: "fixed",
costGroup: "440",
category: "lighting",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
voltageV: 230,
},
{
id: "project-device-foreign",
projectId: "project-2",
name: "Foreign device",
displayName: "Foreign device",
phaseType: "three_phase",
quantity: 1,
powerPerUnit: 1,
simultaneityFactor: 1,
},
])
.run();
context.db
.insert(circuits)
.values([
{
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
{
id: "circuit-foreign",
circuitListId: foreignBoard.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 10,
name: "Luminaire",
displayName: "Local row 1",
phaseType: "single_phase",
quantity: 1,
powerPerUnit: 0.03,
simultaneityFactor: 1,
overriddenFields: '["displayName","quantity"]',
},
{
id: "row-2",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 20,
name: "Luminaire",
displayName: "Local row 2",
phaseType: "single_phase",
quantity: 2,
powerPerUnit: 0.03,
simultaneityFactor: 1,
overriddenFields: '["displayName"]',
},
])
.run();
return context;
}
function getProjectDevice(
context: DatabaseContext,
id: string
) {
return context.db
.select()
.from(projectDevices)
.where(eq(projectDevices.id, id))
.get();
}
function getLinkedRowState(context: DatabaseContext) {
return context.db
.select({
id: circuitDeviceRows.id,
linkedProjectDeviceId:
circuitDeviceRows.linkedProjectDeviceId,
displayName: circuitDeviceRows.displayName,
quantity: circuitDeviceRows.quantity,
overriddenFields: circuitDeviceRows.overriddenFields,
})
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, "circuit-1"))
.orderBy(asc(circuitDeviceRows.id))
.all();
}
describe("project-device structure project-command repository", () => {
it("inserts a stable project device and supports undo and redo", () => {
const context = createTestDatabase();
try {
const store =
new ProjectDeviceStructureProjectCommandRepository(
context.db
);
const command = createProjectDeviceInsertProjectCommand(
newProjectDeviceSnapshot()
);
const inserted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(
getProjectDevice(context, "project-device-new")
?.displayName,
"Office socket"
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(
getProjectDevice(context, "project-device-new"),
undefined
);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId:
inserted.revision.changeSetId,
command,
});
assert.deepEqual(
new ProjectHistoryRepository(context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: inserted.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
context.close();
}
});
it("deletes a device and restores its exact linked rows", () => {
const context = createTestDatabase();
try {
const store =
new ProjectDeviceStructureProjectCommandRepository(
context.db
);
const beforeRows = getLinkedRowState(context);
const command = createProjectDeviceDeleteProjectCommand(
"project-device-1",
"project-1"
);
const deleted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(
getProjectDevice(context, "project-device-1"),
undefined
);
assert.deepEqual(
getLinkedRowState(context).map(
(row) => row.linkedProjectDeviceId
),
[null, null]
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
deleted.revision.changeSetId,
command: deleted.inverse,
});
assert.equal(
getProjectDevice(context, "project-device-1")
?.displayName,
"Office lighting"
);
assert.deepEqual(getLinkedRowState(context), beforeRows);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId:
deleted.revision.changeSetId,
command,
});
assert.deepEqual(
getLinkedRowState(context).map(
(row) => row.linkedProjectDeviceId
),
[null, null]
);
} finally {
context.close();
}
});
it("rejects unsafe user links, duplicate ids and foreign deletes", () => {
const context = createTestDatabase();
try {
const store =
new ProjectDeviceStructureProjectCommandRepository(
context.db
);
const disconnectedRow = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get();
assert.ok(disconnectedRow);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceInsertProjectCommand(
newProjectDeviceSnapshot(),
[
{
...disconnectedRow,
linkedProjectDeviceId: null,
},
]
),
}),
/cannot reconnect existing rows/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceInsertProjectCommand({
...newProjectDeviceSnapshot(),
id: "project-device-1",
}),
}),
/id already exists/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceDeleteProjectCommand(
"project-device-foreign",
"project-1"
),
}),
/does not belong to project/
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
it("refuses undo when a disconnected row changed", () => {
const context = createTestDatabase();
try {
const store =
new ProjectDeviceStructureProjectCommandRepository(
context.db
);
const deleted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceDeleteProjectCommand(
"project-device-1",
"project-1"
),
});
context.db
.update(circuitDeviceRows)
.set({ displayName: "Changed while disconnected" })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
deleted.revision.changeSetId,
command: deleted.inverse,
}),
/row changed before insertion/
);
assert.equal(
getProjectDevice(context, "project-device-1"),
undefined
);
assert.deepEqual(
getLinkedRowState(context).map(
(row) => row.linkedProjectDeviceId
),
[null, null]
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
1
);
} finally {
context.close();
}
});
it("rolls back deletion, disconnected links and history together", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_project_device_structure_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced project-device structure history failure');
END;
`);
const store =
new ProjectDeviceStructureProjectCommandRepository(
context.db
);
const beforeRows = getLinkedRowState(context);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceDeleteProjectCommand(
"project-device-1",
"project-1"
),
}),
/forced project-device structure history failure/
);
assert.equal(
getProjectDevice(context, "project-device-1")
?.displayName,
"Office lighting"
);
assert.deepEqual(getLinkedRowState(context), beforeRows);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
it("rolls back insertion for a stale project revision", () => {
const context = createTestDatabase();
try {
const store =
new ProjectDeviceStructureProjectCommandRepository(
context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createProjectDeviceInsertProjectCommand(
newProjectDeviceSnapshot()
),
}),
/at revision 0, expected 1/
);
assert.equal(
getProjectDevice(context, "project-device-new"),
undefined
);
} finally {
context.close();
}
});
});
+73 -69
View File
@@ -18,6 +18,7 @@ function projectDevice() {
totalPower: 0.192,
cosPhi: 0.95,
remark: "DALI",
voltageV: 230,
};
}
@@ -55,8 +56,6 @@ function linkedRow() {
function createService() {
const rows = [linkedRow()];
const updates: Array<Record<string, unknown>> = [];
let transactionCalls = 0;
const service = new ProjectDeviceSyncService({
projectDeviceRepository: {
async findById() {
@@ -65,99 +64,104 @@ function createService() {
},
deviceRowRepository: {
async listLinkedByProjectDevice() {
return rows.filter((row) => row.linkedProjectDeviceId === "pd1") as never;
return rows as never;
},
async listLinkStatesByIds() {
return rows.map((row) => ({ id: row.id, linkedProjectDeviceId: row.linkedProjectDeviceId })) as never;
},
} as never,
deviceRowSyncStore: {
updateLinkedRows(_projectDeviceId, changes) {
transactionCalls += 1;
for (const change of changes) {
updates.push({ rowId: change.rowId, ...change.input });
Object.assign(rows.find((row) => row.id === change.rowId)!, change.input);
}
},
disconnectLinkedRows(_projectDeviceId, rowIds) {
transactionCalls += 1;
for (const rowId of rowIds) {
const row = rows.find((entry) => entry.id === rowId)!;
updates.push({ rowId, linkedProjectDeviceId: undefined, displayName: row.displayName, quantity: row.quantity });
Object.assign(row, { linkedProjectDeviceId: null });
}
},
reconnectRows(projectDeviceId, rowIds) {
transactionCalls += 1;
for (const rowId of rowIds) {
Object.assign(rows.find((row) => row.id === rowId)!, { linkedProjectDeviceId: projectDeviceId });
}
},
} as never,
},
});
return { service, rows, updates, transactionCalls: () => transactionCalls };
return { service, rows };
}
describe("project device synchronization", () => {
it("shows field differences and local overrides without changing rows", async () => {
const { service, updates } = createService();
const { service, rows } = createService();
const before = structuredClone(rows);
const preview = await service.getPreview("p1", "pd1");
assert.equal(updates.length, 0);
assert.deepEqual(rows, before);
assert.equal(preview.rows[0].equipmentIdentifier, "-1F1");
assert.equal(preview.rows[0].differences.some((difference) => difference.field === "quantity"), true);
assert.equal(
preview.rows[0].differences.find((difference) => difference.field === "displayName")?.isOverridden,
preview.rows[0].differences.some(
(difference) => difference.field === "quantity"
),
true
);
assert.equal(
preview.rows[0].differences.find(
(difference) => difference.field === "displayName"
)?.isOverridden,
true
);
});
it("synchronizes only explicitly selected fields", async () => {
const { service, rows, transactionCalls } = createService();
await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
it("creates a deterministic command for selected synchronization fields", async () => {
const { service, rows } = createService();
const before = structuredClone(rows);
const command = await service.createSynchronizeCommand(
"p1",
"pd1",
["row1"],
["quantity", "powerPerUnit"]
);
assert.equal(rows[0].quantity, 6);
assert.equal(rows[0].powerPerUnit, 0.04);
assert.equal(rows[0].displayName, "Local display name");
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName"]);
assert.equal(transactionCalls(), 1);
assert.deepEqual(rows, before);
assert.equal(command.payload.operation, "synchronize");
assert.equal(command.payload.rows[0].expected.quantity, 2);
assert.equal(command.payload.rows[0].target.quantity, 6);
assert.equal(command.payload.rows[0].target.powerPerUnit, 0.04);
assert.equal(
command.payload.rows[0].target.displayName,
"Local display name"
);
assert.deepEqual(
JSON.parse(command.payload.rows[0].target.overriddenFields ?? "[]"),
["displayName"]
);
});
it("rejects rows that are not linked to the selected project device", async () => {
const { service } = createService();
await assert.rejects(
() => service.synchronize("p1", "pd1", ["other-row"], ["quantity"]),
() =>
service.createSynchronizeCommand(
"p1",
"pd1",
["other-row"],
["quantity"]
),
/not linked/
);
});
it("disconnects selected rows without changing local values", async () => {
const { service, updates } = createService();
await service.disconnect("p1", "pd1", ["row1"]);
it("creates a link-only disconnect command", async () => {
const { service } = createService();
const command = await service.createDisconnectCommand(
"p1",
"pd1",
["row1"]
);
assert.equal(updates[0].linkedProjectDeviceId, undefined);
assert.equal(updates[0].displayName, "Local display name");
assert.equal(updates[0].quantity, 2);
const assignment = command.payload.rows[0];
assert.equal(command.payload.operation, "disconnect");
assert.equal(assignment.expected.linkedProjectDeviceId, "pd1");
assert.equal(assignment.target.linkedProjectDeviceId, null);
assert.equal(
assignment.target.displayName,
assignment.expected.displayName
);
assert.equal(assignment.target.quantity, assignment.expected.quantity);
});
it("restores synchronized values and override markers", async () => {
const { service, rows } = createService();
const result = await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
await service.restore("p1", "pd1", result.undo.rows);
assert.equal(rows[0].quantity, 2);
assert.equal(rows[0].powerPerUnit, 0.03);
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName", "quantity"]);
it("does not create no-op synchronization commands", async () => {
const { service } = createService();
await assert.rejects(
() =>
service.createSynchronizeCommand(
"p1",
"pd1",
["row1"],
["category"]
),
/at least one row/
);
});
it("reconnects rows only while they remain disconnected", async () => {
const { service, rows } = createService();
const result = await service.disconnect("p1", "pd1", ["row1"]);
await service.reconnect("p1", "pd1", result.undo.rowIds);
assert.equal(rows[0].linkedProjectDeviceId, "pd1");
});
});