Compare commits

...

12 Commits

Author SHA1 Message Date
jappel 1cf26a932e Persist distribution board setup 2026-07-25 23:41:27 +02:00
jappel 1a77eedaa8 Add automatic project snapshots 2026-07-25 23:27:51 +02:00
jappel 59d442593f Expose project-wide history controls 2026-07-25 23:16:46 +02:00
jappel b2763f72d5 Persist project settings updates 2026-07-25 23:08:07 +02:00
jappel d00ae30bda Expose project version history 2026-07-25 22:56:51 +02:00
jappel 384a5769ab Restore named project snapshots 2026-07-25 22:41:24 +02:00
jappel 7c670fece3 Add named project snapshots 2026-07-25 22:15:22 +02:00
jappel 5a0c9019af Expose project revision timeline 2026-07-25 21:57:03 +02:00
jappel 4b47bc2bcc Restore editor history after reload 2026-07-25 21:45:58 +02:00
jappel c45afd0981 Persist section renumbering 2026-07-25 21:30:29 +02:00
jappel abcb468807 Persist circuit reorders 2026-07-25 21:22:10 +02:00
jappel 08a2775a88 Persist device row moves 2026-07-25 21:09:02 +02:00
100 changed files with 10993 additions and 3116 deletions
+36 -12
View File
@@ -220,15 +220,34 @@ After saving, the row becomes linked to the new project device.
## Undo / Redo
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.
The editor reads undo/redo eligibility from the project-wide server history on
initial load and after every tree reload. Undo/redo therefore remains available
after a page refresh or application restart. All currently supported Circuit
and CircuitDeviceRow writes execute persistent project commands. Applying a
sorted view across multiple sections is one atomic `circuit.reorder-sections`
command and one undo step. Explicit renumbering uses the collision-safe
`circuit.renumber-section` command and is never triggered implicitly.
Immutable revision metadata is available through the paginated
`GET /api/projects/:projectId/history/revisions` endpoint; it does not expose
stored command payloads.
Named logical snapshots can be created and listed through project-scoped API
endpoints. Their schema-versioned payload contains the complete supported
project runtime state and a SHA-256, excludes global/upgrade-only data and does
not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart.
The central revision boundary creates an automatic logical snapshot after each
25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy.
The project page exposes persistent project-wide Undo/Redo in the header of an
initially collapsed German snapshot/timeline UI, with explicit restore
confirmation and cursor-based loading of older revision metadata.
Insertions and generated move targets use client-generated stable UUIDs, and
undo restores the same ids from complete server snapshots. The tree response
supplies the optimistic `currentRevision`; obsolete direct field PATCH,
structure POST, move, reorder, renumber, identifier-restore, Circuit and
CircuitDeviceRow DELETE routes are removed.
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
@@ -246,6 +265,13 @@ 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.
Project voltage settings use the persistent `project.update-settings` command.
Both voltage defaults change in one revision and Undo/Redo restores them
together; the project PUT route requires `expectedRevision`.
Distribution-board setup uses `distribution-board.insert` with a complete
stable snapshot of the board, circuit list and four default sections. Its
inverse removes only the same unchanged and still-empty structure; the POST
route requires `expectedRevision` and returns the updated history state.
Required operations:
@@ -287,7 +313,7 @@ Users must be able to override sizing suggestions.
- Never edit an already applied migration.
- Back up an existing database before applying a new migration.
- Inspect generated SQL; it must contain only the intended schema change.
- Keep database backups separate from future logical project snapshots.
- Keep database backups separate from logical project snapshots.
- Do not import the global SQLite singleton into domain services.
- Keep synchronous SQLite transaction behavior inside persistence adapters.
- Preserve stable UUIDs and explicit transaction boundaries for a later
@@ -295,8 +321,6 @@ Users must be able to override sizing suggestions.
## Current Deferred Work
- complete persistent project command coverage and the undo/redo UI cutover
- named logical snapshots and restore
- Revit/CSV/IFCGUID round-trip
- full electrical sizing
- multi-user/PostgreSQL operation
+5 -3
View File
@@ -6,8 +6,8 @@ Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
persistentes Undo/Redo, Projektversionen und der Revit-/IFCGUID-Datenaustausch sind
noch nicht implementiert.
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
implementiert.
## Unterstützter Arbeitsablauf
@@ -18,7 +18,9 @@ noch nicht implementiert.
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
- komplette Stromkreisblöcke filtern und sortieren
- BMKs stabil halten und nur auf ausdrücklichen Befehl neu nummerieren
- Änderungen innerhalb der aktuellen Editorsitzung rückgängig machen und wiederholen
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
## Technik
+72 -81
View File
@@ -14,6 +14,16 @@ this value for optimistic concurrency checks.
- `GET /projects/:projectId/history`
- returns `currentRevision`, `undoDepth`, `redoDepth` and the current top
change-set id for both persistent stacks
- `GET /projects/:projectId/history/revisions`
- returns revision metadata in descending `revisionNumber` order
- optional query: `limit` (default `25`, maximum `100`) and exclusive
`beforeRevision`
- returns `projectId`, `currentRevision`, `revisions` and
`nextBeforeRevision`; use the latter as the next page's
`beforeRevision`
- each revision contains ids, number, timestamp, actor, source, description,
command type and payload schema version
- stored forward/inverse command payloads are intentionally not exposed
- `POST /projects/:projectId/commands`
- executes a supported versioned command as a new user revision
- body: `{ "expectedRevision": 0, "command": { ... } }`
@@ -22,23 +32,50 @@ this value for optimistic concurrency checks.
- body: `{ "expectedRevision": 1 }`
- executes the eligible inverse or forward command as a new auditable
revision
- `GET /projects/:projectId/snapshots`
- lists named and automatic logical snapshot metadata, including `kind`,
without returning payload JSON
- `POST /projects/:projectId/snapshots`
- body:
`{ "expectedRevision": 12, "name": "Vor Ausschreibung", "description": "Optional" }`
- atomically captures the complete project-scoped runtime state at the
expected revision
- returns `201` with source revision, schema version, SHA-256 and creation
metadata
- snapshot creation does not increment the project revision or alter
undo/redo stacks
- duplicate names and stale revisions return `409`
- the central revision boundary additionally creates `automatic` snapshots
after 25 further revisions and retains the newest 12 per project; named
snapshots are never removed by this policy
- `POST /projects/:projectId/snapshots/:snapshotId/restore`
- body: `{ "expectedRevision": 13 }`
- verifies the stored payload checksum and atomically restores the complete
supported project state as revision `14` with source `restore`
- stores the complete pre-restore state as the inverse command, so project
Undo/Redo can revert or repeat the restore after a restart
- stale revisions or state checks return `409`; unknown snapshots return
`404`
- clients cannot submit arbitrary `project.restore-state` payloads through
the generic command endpoint
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit.delete`, `circuit-device-row.update`,
`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
`circuit.reorder-section`, `circuit.reorder-sections`,
`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
`project-device.sync-rows` as well as `project.update-settings`, 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.
CircuitDeviceRow cell edits, standalone insert/delete actions, device-row
moves, reorders and explicit renumbering in the editor consume these endpoints.
`circuit-device-row.move` targets existing circuits in the same circuit list.
`circuit-device-row.move-with-new-circuit` atomically creates exactly one
@@ -52,6 +89,11 @@ 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.reorder-sections` contains one complete assignment block per affected
section. All blocks are validated before any write and commit or roll back
together as one revision and one undo step. The editor uses it when applying a
sorted view that changes multiple sections.
`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
@@ -99,6 +141,18 @@ These write endpoints execute the typed `project-device.update`,
returns `409 PROJECT_REVISION_CONFLICT`. Updating a project device still never
synchronizes linked circuit rows implicitly.
### Project Settings
- `PUT /projects/:projectId`
- request:
`{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400 }`
- executes `project.update-settings` as one atomic revision
- response:
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
- persistent Undo/Redo restores both voltage values together
- unchanged values are rejected without creating a revision; a stale
revision returns `409 PROJECT_REVISION_CONFLICT`
Example:
```json
@@ -127,7 +181,14 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
### Distribution Board Setup
- `POST /projects/:projectId/distribution-boards`
- atomically creates the distribution board, its circuit list and all default circuit sections
- body: `{ "name": "UV-02", "expectedRevision": 12 }`
- executes `distribution-board.insert` with stable ids for the distribution
board, its circuit list and all four default circuit sections
- response:
`{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }`
- persistent Undo removes only the unchanged and still-empty generated
structure; Redo restores the same ids
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
### Tree Endpoint
@@ -169,82 +230,12 @@ Response sketch:
- `GET /circuit-sections/:sectionId/next-identifier`
- preview next identifier for section (`prefix + maxSuffix + 1`)
Circuit and device-row field updates, standalone insertions and standalone
deletions are available only as the corresponding versioned commands through
Circuit and device-row field updates, standalone insertions/deletions, single
or bulk device-row moves, circuit reorders and explicit renumbering 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
- `PATCH /circuit-device-rows/:rowId/move`
- Purpose: move one row to existing circuit or to newly created circuit in target section.
Request sketch:
```json
{
"targetCircuitId": "cir_target"
}
```
or
```json
{
"targetSectionId": "sec_target",
"createNewCircuit": true
}
```
### Bulk Move Device Rows
- `PATCH /circuit-device-rows/move-bulk`
- Purpose: move multiple rows in one command flow.
Request sketch:
```json
{
"rowIds": ["row_1", "row_2"],
"targetCircuitId": "cir_target"
}
```
### Reorder Circuits
- `PATCH /circuit-sections/:sectionId/circuits/reorder`
- Purpose: persist explicit circuit order within one section.
Request sketch:
```json
{
"orderedCircuitIds": ["cir_2", "cir_1", "cir_3"]
}
```
### Renumber Section
- `POST /circuit-sections/:sectionId/renumber`
- Purpose: explicit renumbering by section prefix; never implicit on move/sort.
### Safe Equipment Identifier Update
- `PATCH /circuit-sections/:sectionId/equipment-identifiers`
- Purpose: apply explicit per-circuit identifiers safely even with unique constraints.
Request sketch:
```json
{
"identifiers": [
{ "circuitId": "cir_1", "equipmentIdentifier": "-2F1" },
{ "circuitId": "cir_2", "equipmentIdentifier": "-2F2" }
]
}
```
structure POST, move, reorder, renumber, identifier-restore, Circuit DELETE or
CircuitDeviceRow DELETE routes.
## Removed Legacy Endpoints
+14 -12
View File
@@ -122,13 +122,16 @@ Cross-section device moves show confirmation-required feedback before drop. The
## Undo/Redo
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.
Editor operations execute persistent project commands and reload the tree after
each command. On initial load and every reload, the editor reads undo/redo
eligibility from the project-wide server history and verifies that tree and
history belong to the same project revision. Undo/redo therefore remains
available after a page refresh. New entities receive stable UUIDs before
insertion, and deletion undo restores those same ids. Device-row moves and
circuit reorders also use persistent project commands. Applying a sorted view
across multiple sections is atomic and occupies one history step. Explicit
renumbering uses a collision-safe persistent project command and one
project-history step.
Covered operations include:
@@ -139,8 +142,7 @@ Covered operations include:
- renumber and identifier update flows
- apply sorted order
Current limitations:
- 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
The editor toolbar exposes availability through its Undo/Redo buttons. The
project page provides the same persistent project-wide controls in the header
of its version card, including after a reload. Named restore points and the
revision timeline remain inside the expandable part of that card.
@@ -1,14 +1,14 @@
# Circuit List Editor Known Limitations
- 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.
- All currently supported Circuit and CircuitDeviceRow editor writes use
persistent project commands and project-wide toolbar undo/redo, including
moves, atomic multi-section reorders and explicit renumbering. Undo/redo
eligibility is restored from server history after a page reload.
- The project page exposes the paginated revision timeline; the circuit editor
itself currently exposes only the next eligible Undo/Redo actions.
- Named and automatic logical project snapshots can be listed and restored
through the project page with persistent Undo/Redo. Automatic snapshots are
created every 25 revisions and only their newest 12 entries are retained.
- 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
+83 -23
View File
@@ -62,28 +62,31 @@ 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.
Der sichtbare React-Historiestapel ist während des schrittweisen Cutovers noch
sitzungslokal. Das Datenmodell besitzt
einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
Der Editor besitzt keinen sitzungslokalen Undo-/Redo-Stapel mehr. Beim initialen
Laden und nach jedem Tree-Reload liest er den persistenten History-Status und
gleicht dessen Revision mit `currentRevision` des Trees ab. 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
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und
bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze
angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren interne
Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und
Revision gemeinsam committen beziehungsweise zurückrollen können.
einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten
Circuit-, Gerätezeilen-, Projektgeräte- und Projekteinstellungsänderungen
verwenden typisierte Command-Stores, die Fachänderung, automatisch erzeugtes
inverses Kommando, Revision und Historienstapel gemeinsam committen
beziehungsweise zurückrollen.
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
`GET /api/projects/:projectId/history` lesbar. Ein zentraler Dispatcher führt
die unterstützten Typen `circuit.update` und `circuit-device-row.update` über
öffentliche Command-, Undo- und Redo-Endpunkte aus. Zusätzlich sind
`circuit-device-row.insert` und `circuit-device-row.delete` als atomare
Strukturkommandos vorhanden. Beim Löschen wird die vollständige Zeile im
inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte
wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen
`GET /api/projects/:projectId/history` lesbar. Die unveränderliche,
absteigend paginierte Revisions-Timeline ist ohne Befehls-Payloads über
`GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler
Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über
öffentliche Command-, Undo- und Redo-Endpunkte aus.
`circuit-device-row.insert` und `circuit-device-row.delete` sind atomare
Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen
Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte wiederherstellt.
`circuit.insert` und `circuit.delete` behandeln einen
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen
@@ -91,10 +94,43 @@ sowie eigenständiges Einfügen und Löschen verwendet das Grid bereits über di
ö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.
Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und
Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves,
Stromkreis-Reorders und die explizite Neunummerierung im Grid verwenden die
persistenten Kommandos. Die Toolbar leitet ihre Undo-/Redo-Verfügbarkeit direkt
aus den serverseitigen Stack-Tiefen ab, sodass ein Reload die Bedienbarkeit
nicht unterbricht.
Benannte logische Projektstände werden in `project_snapshots` getrennt von
Datenbank-Backups gespeichert. `POST /api/projects/:projectId/snapshots`
erzeugt bei passender erwarteter Revision transaktional einen vollständigen,
schema-versionierten Projektzustand mit SHA-256-Prüfwert. Enthalten sind
Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und
Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte,
Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots.
Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel.
`kind` unterscheidet benannte und automatische Stände. Die zentrale
Revisionspersistenz erzeugt nach jeweils 25 weiteren Projektänderungen
transaktional einen automatischen Stand. Pro Projekt bleiben die neuesten 12
automatischen Stände erhalten; ältere automatische Stände werden in derselben
Transaktion entfernt. Benannte Stände und die unveränderliche Revisionshistorie
sind von dieser Aufbewahrung ausdrücklich ausgeschlossen.
`POST /api/projects/:projectId/snapshots/:snapshotId/restore` prüft Payload,
Prüfsumme, erwartete Revision und den unmittelbar zuvor gelesenen
Projektzustand. Der Restore ersetzt alle unterstützten Projektdaten in einer
Transaktion und schreibt dabei eine neue Revision mit Quelle `restore` sowie
ein vollständiges inverses Kommando. Undo und Redo können deshalb auch einen
Restore nach einem Neustart exakt zurücknehmen oder wiederholen. Kompatible
Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie
nicht Bestandteil des logischen Snapshots sind.
Die Projektseite bindet diese APIs in einem einklappbaren Bereich
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
benennen, nach expliziter Bestätigung wiederherstellen und die paginierte
Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen.
Projektweites Rückgängig/Wiederholen bleibt im Kopf dieses Bereichs auch im
eingeklappten Zustand erreichbar. Die Verfügbarkeit stammt direkt aus den
persistierten Server-Stacks; nach einer Historienaktion oder einem Restore lädt
die Seite sämtliche Projektdaten neu.
`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;
@@ -104,6 +140,9 @@ 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.
Der Editor erzeugt die vollständigen Move-Zuweisungen aus dem geladenen Tree,
vergibt für neue Ziele vor dem Kommando eine stabile UUID und führt das
Toolbar-Undo/Redo über die projektweite Historie aus.
`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
@@ -111,6 +150,15 @@ 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.
Die Bereichsaktion erzeugt die vollständigen erwarteten und neuen BMKs aus
Präfix und aktueller Stromkreisreihenfolge und verwendet das persistente
Kommando für Toolbar-Undo/Redo. Die früheren direkten Renumber- und
Identifier-Restore-Endpunkte sowie ihr separater Transaktionsadapter sind
entfernt.
Drag-and-drop verwendet `circuit.reorder-section`. Die explizite Übernahme
einer sortierten Ansicht verwendet `circuit.reorder-sections`, damit alle
betroffenen Bereiche in einer Transaktion und als ein Undo-Schritt gespeichert
werden. Direkte Reorder-Endpunkte existieren nicht mehr.
`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,
@@ -118,8 +166,9 @@ 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
optimistischer Revisionsprüfung. Rückgängig/Wiederholen erfolgt dort
einheitlich über die persistente projektweite Historie; der frühere
sitzungslokale Spezial-Undo und 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
@@ -136,6 +185,19 @@ unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revisi
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.
`project.update-settings` versioniert die beiden Projekt-Standardspannungen als
eine atomare Änderung. Der Store leitet das inverse Kommando aus dem
gespeicherten Projekt ab und schreibt Werte, Revision und Historienstapel
gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb
`expectedRevision`, liefert Projekt plus aktualisierten Historienstand und
besitzt keinen separaten direkten Settings-Schreibweg mehr.
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
`distribution-board.delete` ist die persistierte Inverse und entfernt nur den
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
Projektseite übergeben die erwartete Projektrevision; der frühere direkte
Controller-Schreibweg ist entfernt.
## Projektgeräte
@@ -167,8 +229,6 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Noch nicht unterstützt
- 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
+4 -23
View File
@@ -130,27 +130,8 @@ Expected response shape:
If migrations were not applied, endpoint may return an empty fallback with a warning.
## Dev-only visual test helper (multi-device circuit)
## Visual verification
Add one extra manual device row to an existing circuit:
```bash
npm run dev:add-manual-circuit-row -- <circuitId>
```
Default inserted values:
- `name`: `Test sub device`
- `displayName`: `Beleuchtung WC`
- `phaseType`: `single_phase`
- `quantity`: `1`
- `powerPerUnit`: `0.05`
- `simultaneityFactor`: `1`
- `cosPhi`: `1`
The script prints the created row id.
Delete the test row again:
```bash
npm run dev:delete-circuit-row -- <rowId>
```
Manual device rows and multi-device circuits can be created directly in the
circuit-list editor. The former development scripts for direct database writes
were removed because they bypassed project revisions and persistent undo/redo.
@@ -60,7 +60,9 @@ Full event sourcing is not required. Current normalized tables remain the primar
- After a page reload, the client requests the current revision and eligible history actions.
- Commands include an expected project revision. A stale command is rejected instead of silently overwriting a newer project state.
The current session-local frontend command stack is transitional. Editor modularization must separate command descriptions from React callbacks so commands can later be sent to and reconstructed by the server.
The circuit-list editor uses the server state directly for operation
eligibility. React callbacks only initiate forward commands and provide
best-effort selection hints after the tree reload.
## Snapshot Storage
@@ -73,6 +75,43 @@ Project history must use logical project data, not copies of the SQLite database
Large snapshots may later be compressed or stored in object storage, with metadata and checksums retained in the database.
Implemented named-snapshot foundation:
- `project_snapshots` stores project ownership, source revision, schema
version, name, optional description, logical payload, SHA-256 and creation
metadata
- snapshot creation requires the expected current revision and reads plus
stores the complete project state in one SQLite transaction
- the versioned payload contains project settings, distribution boards,
circuit lists, sections, circuits with device rows, project devices, floors
and rooms while excluding global and upgrade-only data
- snapshot creation/listing does not create a project revision or affect
undo/redo eligibility
- public Create/List endpoints expose metadata but not the logical payload
- restore verifies the stored checksum and a hash of the complete current
state before replacing supported project data in one transaction
- restore records a new `restore` revision containing both the selected target
state and the complete pre-restore inverse; undo/redo therefore remains
available after application restarts
- compatible upgrade-only Consumer links, mappings and reports are preserved
separately and are never imported into the logical snapshot payload
- arbitrary restore payloads are rejected by the generic user-command
endpoint; only server-stored snapshots can initiate a forward restore
- the project page provides an initially collapsed snapshot/timeline panel,
explicit inline restore confirmation and cursor-based loading of older
revisions; a successful restore reloads all project read models
Implemented automatic-snapshot policy:
- `project_snapshots.kind` distinguishes `named` from `automatic`
- the central revision boundary captures the complete post-command state after
every 25 additional project revisions
- the newest 12 automatic snapshots per project are retained
- retention never removes named snapshots, immutable revisions or physical
database backups
- automatic capture and retention run inside the same transaction as the
triggering project revision
## Revit / CSV / IFCGUID Round-Trip Direction
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
@@ -168,6 +207,9 @@ Completed foundation:
a forced stack failure rolls all of them back
- `GET /api/projects/:projectId/history` exposes current revision, stack depths
and top change-set ids
- `GET /api/projects/:projectId/history/revisions` exposes a descending,
cursor-paginated metadata timeline without leaking persisted forward or
inverse command payloads
- a central application dispatcher executes the supported `circuit.update`
and `circuit-device-row.update` envelopes without coupling the domain service
to SQLite
@@ -194,12 +236,25 @@ 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
- the circuit-list editor executes existing-target and generated-target row
moves through these commands; obsolete direct move and Circuit DELETE routes
plus their separate transaction adapter are removed
- `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.reorder-sections` applies the same invariants to every affected
section from a sorted view in one transaction and one history entry
- circuit drag-and-drop and sorted-order application use these commands; the
obsolete direct reorder route and transaction method are removed
- `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
- the editor's explicit renumber action uses this command; obsolete direct
renumber and identifier-restore routes plus their transaction adapter are
removed
- the circuit-list editor loads project-wide undo/redo eligibility together
with the tree, retries revision mismatches and keeps Undo/Redo available after
page reloads without a session-local command stack
- `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
@@ -208,15 +263,16 @@ Completed foundation:
- `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
- `project.update-settings` persists both project voltage defaults, its exact
inverse, revision and history transition atomically; the project page and
existing PUT route require the current expected revision
- `distribution-board.insert` persists the complete generated board, circuit
list and default-section structure with stable ids; its delete inverse
refuses changed or populated structures and the project-page POST tracks the
returned revision
Remaining constraints before completing persistent history:
Remaining constraints before completing project version history:
- 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
@@ -224,7 +280,6 @@ Remaining constraints before completing persistent history:
## Deferred Decisions
- exact snapshot frequency and retention policy
- whether large snapshot payloads remain in PostgreSQL or move to object storage
- branch visualization after undo followed by new edits
- user/role model and actor attribution
+39 -9
View File
@@ -335,7 +335,7 @@ Implemented foundation:
- a new circuit and all of its initial device rows use the same transaction store
- device-row moves, reserve-state updates and optional target creation share one transaction
- project-device synchronization, disconnect and reconnect use a separate injected transaction store
- section renumbering and circuit reordering use a separate injected transaction store
- section renumbering and circuit reordering use persistent command stores
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
- BMK swaps and section reorder have real SQLite commit and rollback coverage
@@ -358,14 +358,23 @@ Tasks:
- [x] add immutable append-only server-side change-set storage with versioned
forward/inverse payloads
- [x] add optimistic checks when appending a revision
- route domain writes and history records through one shared transaction
- define concrete serializable domain command descriptions and executors
- [x] route supported domain writes and history records through one shared transaction
- [x] define concrete serializable domain command descriptions and executors
outside React
- [x] persist undo/redo eligibility on the server
- [x] expose public command, undo and redo endpoints for the first supported
update command types
- add named and periodic logical project snapshots
- restore a historical snapshot as a new revision
- [x] expose a cursor-paginated, read-only revision metadata timeline without
command payloads
- [x] add explicitly named logical project snapshots with schema-versioned
payloads and integrity checksums
- [x] add periodic logical project snapshots with a bounded retention policy
- [x] restore a historical snapshot as a new auditable revision with
persistent undo/redo
- [x] expose named snapshots, confirmed restore and the paginated revision
timeline on the project page
- [x] expose persistent project-wide undo/redo controls on the project page
and remove its session-local ProjectDevice synchronization undo state
- keep database backups separate from logical history
Implemented foundation:
@@ -387,6 +396,18 @@ Implemented foundation:
late-failure rollback coverage
- a read-only project history endpoint exposes revision, stack depths and top
change-set ids
- a separate read-only timeline endpoint exposes immutable revision metadata
in descending, cursor-paginated pages without forward/inverse payloads
- named snapshot Create/List endpoints persist complete project-scoped runtime
state with the source revision and SHA-256 without changing revision or
undo/redo stacks
- the central revision boundary captures an `automatic` snapshot after every
25 new revisions and retains the newest 12 automatic snapshots per project
without deleting named snapshots or immutable revisions
- the project page lists, creates and explicitly restores named snapshots and
displays paginated revision metadata without exposing command payloads
- the version-card header reads persisted undo/redo eligibility and executes
optimistic project-wide undo/redo even while its timeline body is collapsed
- a central application dispatcher reconstructs persisted commands and exposes
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
field updates
@@ -424,6 +445,12 @@ Implemented foundation:
- 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
- project voltage settings use `project.update-settings`; both values, inverse,
revision and history transition commit atomically and the former direct
settings write is removed
- distribution-board setup uses `distribution-board.insert` with a complete
stable board/list/default-section snapshot; Undo removes only an unchanged
empty setup and the former direct controller write is removed
- revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction
- a stale expected revision produces no history writes
@@ -431,10 +458,13 @@ Implemented foundation:
and rollback after a forced late persistence failure
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.
Circuit/CircuitDeviceRow field/insert/delete actions, device-row moves and
circuit reorders in the circuit-list editor are connected to this history
boundary. Applying sorted order across multiple sections is one atomic command.
Explicit renumbering is connected to the same persistent boundary. The
editor reads the persistent undo/redo stack depths on initial load and after
every tree reload. Tree and history revisions are reconciled before the state
is exposed, so the toolbar remains usable after a page refresh.
Acceptance criteria:
+3 -5
View File
@@ -14,16 +14,14 @@
"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-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",
"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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.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-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/distribution-board-structure-project-command.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-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-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.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-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/distribution-board-structure-project-command.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-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-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.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",
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
"db:backfill:sections": "tsx scripts/db-backfill-sections.ts",
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts",
"dev:add-manual-circuit-row": "tsx scripts/dev-add-manual-circuit-row.ts",
"dev:delete-circuit-row": "tsx scripts/dev-delete-circuit-row.ts"
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts"
},
"keywords": [],
"author": "",
-39
View File
@@ -1,39 +0,0 @@
import { db } from "../src/db/client.js";
import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
async function run() {
const circuitId = process.argv[2];
if (!circuitId) {
console.error("Usage: npm run dev:add-manual-circuit-row -- <circuitId>");
process.exit(1);
}
const circuitRepository = new CircuitRepository();
const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db);
const circuit = await circuitRepository.findById(circuitId);
if (!circuit) {
console.error(`Circuit not found: ${circuitId}`);
process.exit(1);
}
const createdRowId = rowTransactionRepository.createInCircuit({
circuitId,
name: "Test sub device",
displayName: "Beleuchtung WC",
phaseType: "single_phase",
quantity: 1,
powerPerUnit: 0.05,
simultaneityFactor: 1,
cosPhi: 1,
});
console.log(`Created test row id: ${createdRowId}`);
}
run().catch((error) => {
console.error("Failed to create test row:", error);
process.exit(1);
});
-28
View File
@@ -1,28 +0,0 @@
import { db } from "../src/db/client.js";
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
async function run() {
const rowId = process.argv[2];
if (!rowId) {
console.error("Usage: npm run dev:delete-circuit-row -- <rowId>");
process.exit(1);
}
const rowRepository = new CircuitDeviceRowRepository();
const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db);
const row = await rowRepository.findById(rowId);
if (!row) {
console.error(`Row not found: ${rowId}`);
process.exit(1);
}
rowTransactionRepository.deleteFromCircuit(rowId, row.circuitId);
console.log(`Deleted row id: ${rowId}`);
}
run().catch((error) => {
console.error("Failed to delete row:", error);
process.exit(1);
});
+40 -71
View File
@@ -21,7 +21,6 @@ import {
listProjects,
listRooms,
synchronizeProjectDeviceRows,
undoProjectCommand,
updateProjectDevice,
updateProjectSettings,
} from "../../../frontend/utils/api";
@@ -40,6 +39,7 @@ import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
} from "../../../shared/constants/project-device-sync-fields";
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
name: "Technischer Name",
@@ -55,11 +55,6 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
remark: "Bemerkung",
};
type ProjectDeviceUndoState = {
projectDeviceId: string;
revisionNumber: number;
};
const emptyProjectDevice: CreateProjectDeviceInput = {
name: "",
displayName: "",
@@ -116,7 +111,6 @@ export default function ProjectDetailPage() {
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
const [projectDeviceUndo, setProjectDeviceUndo] = useState<ProjectDeviceUndoState | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
@@ -179,20 +173,27 @@ export default function ProjectDetailPage() {
setProject((current) =>
current ? { ...current, currentRevision } : current
);
setProjectDeviceUndo(null);
}
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !boardName.trim()) {
if (!projectId || !project || !boardName.trim()) {
return;
}
setIsSaving(true);
setError(null);
try {
const created = await createDistributionBoard(projectId, boardName.trim());
setBoards((current) => [...current, created]);
const result = await createDistributionBoard(
projectId,
boardName.trim(),
project.currentRevision
);
setBoards((current) => [
...current,
result.distributionBoard,
]);
setCircuitLists(await listCircuitLists(projectId));
applyProjectRevision(result.history.currentRevision);
setBoardName("");
} catch (err) {
setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden.");
@@ -244,17 +245,21 @@ export default function ProjectDetailPage() {
}
async function handleSaveProjectSettings() {
if (!projectId) {
if (!projectId || !project) {
return;
}
setIsSaving(true);
setError(null);
try {
const updated = await updateProjectSettings(projectId, {
singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV),
});
setProject(updated);
const result = await updateProjectSettings(
projectId,
project.currentRevision,
{
singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV),
}
);
setProject(result.project);
} catch (err) {
setError(err instanceof Error ? err.message : "Projekteigenschaften konnten nicht gespeichert werden.");
} finally {
@@ -381,7 +386,6 @@ export default function ProjectDetailPage() {
return;
}
try {
setProjectDeviceUndo(null);
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
@@ -433,10 +437,6 @@ export default function ProjectDetailPage() {
setSelectedSyncRowIds(
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
);
setProjectDeviceUndo({
projectDeviceId: syncPreview.projectDevice.id,
revisionNumber: result.revision.revisionNumber,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
} finally {
@@ -469,10 +469,6 @@ export default function ProjectDetailPage() {
.map((row) => row.rowId)
);
applyProjectRevision(result.history.currentRevision);
setProjectDeviceUndo({
projectDeviceId: syncPreview.projectDevice.id,
revisionNumber: result.revision.revisionNumber,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
} finally {
@@ -480,40 +476,6 @@ export default function ProjectDetailPage() {
}
}
async function handleUndoProjectDeviceOperation() {
if (!projectId || !project || !projectDeviceUndo) {
return;
}
setIsSaving(true);
setError(null);
try {
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);
} catch (err) {
setError(err instanceof Error ? err.message : "Die letzte Aktion konnte nicht rückgängig gemacht werden.");
} finally {
setIsSaving(false);
}
}
async function handleCopyGlobalToProject() {
if (!projectId || !project || !selectedGlobalDeviceId) {
return;
@@ -598,7 +560,14 @@ export default function ProjectDetailPage() {
className="btn btn-primary w-100"
type="button"
onClick={handleSaveProjectSettings}
disabled={isSaving}
disabled={
isSaving ||
!project ||
(Number(singlePhaseVoltageV) ===
project.singlePhaseVoltageV &&
Number(threePhaseVoltageV) ===
project.threePhaseVoltageV)
}
>
Projekteigenschaften speichern
</button>
@@ -608,6 +577,16 @@ export default function ProjectDetailPage() {
</div>
</section>
{project ? (
<section className="col-12">
<ProjectVersionHistory
currentRevision={project.currentRevision}
onProjectStateChange={() => window.location.reload()}
projectId={projectId}
/>
</section>
) : null}
<section className="col-12 col-lg-4">
<div className="card shadow-sm">
<div className="card-header">Neue Verteilung</div>
@@ -1141,16 +1120,6 @@ export default function ProjectDetailPage() {
</div>
<div className="d-flex flex-wrap gap-2">
{projectDeviceUndo ? (
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => void handleUndoProjectDeviceOperation()}
disabled={isSaving}
>
Letzte Aktion rückgängig
</button>
) : null}
<button
className="btn btn-primary"
type="button"
@@ -0,0 +1,16 @@
CREATE TABLE `project_snapshots` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_revision` integer NOT NULL,
`schema_version` integer NOT NULL,
`name` text NOT NULL,
`description` text,
`payload_json` text NOT NULL,
`payload_sha256` text NOT NULL,
`created_at_iso` text NOT NULL,
`created_by_actor_id` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `project_snapshots_project_created_idx` ON `project_snapshots` (`project_id`,`created_at_iso`);--> statement-breakpoint
CREATE UNIQUE INDEX `project_snapshots_project_name_unique` ON `project_snapshots` (`project_id`,`name`);
@@ -0,0 +1,2 @@
ALTER TABLE `project_snapshots` ADD `kind` text DEFAULT 'named' NOT NULL;--> statement-breakpoint
CREATE INDEX `project_snapshots_project_kind_revision_idx` ON `project_snapshots` (`project_id`,`kind`,`source_revision`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -99,6 +99,20 @@
"when": 1784835703230,
"tag": "0013_project_history_stacks",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1785009799979,
"tag": "0014_project_snapshots",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1785014383032,
"tag": "0015_modern_madame_masque",
"breakpoints": true
}
]
}
}
@@ -0,0 +1,111 @@
import crypto from "node:crypto";
import { and, desc, eq, inArray } from "drizzle-orm";
import {
automaticProjectSnapshotIntervalRevisions,
automaticProjectSnapshotRetentionCount,
shouldCaptureAutomaticProjectSnapshot,
} from "../../domain/models/project-snapshot-policy.model.js";
import { projectStateSnapshotSchemaVersion } from "../../domain/models/project-state-snapshot.model.js";
import type { ProjectSnapshotMetadata } from "../../domain/ports/project-snapshot.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectSnapshots } from "../schema/project-snapshots.js";
import { readProjectStateSnapshot } from "./project-state-snapshot.persistence.js";
interface CaptureAutomaticProjectSnapshotInput {
projectId: string;
currentRevision: number;
createdAtIso: string;
actorId?: string;
}
export function captureAutomaticProjectSnapshot(
database: AppDatabase,
input: CaptureAutomaticProjectSnapshotInput
): ProjectSnapshotMetadata | null {
const latest = database
.select({ sourceRevision: projectSnapshots.sourceRevision })
.from(projectSnapshots)
.where(
and(
eq(projectSnapshots.projectId, input.projectId),
eq(projectSnapshots.kind, "automatic")
)
)
.orderBy(desc(projectSnapshots.sourceRevision))
.get();
if (
!shouldCaptureAutomaticProjectSnapshot(
input.currentRevision,
latest?.sourceRevision ?? null
)
) {
return null;
}
const current = readProjectStateSnapshot(database, input.projectId);
if (!current || current.currentRevision !== input.currentRevision) {
throw new Error(
"Automatic snapshot state does not match the appended revision."
);
}
const id = crypto.randomUUID();
const preferredName = `Automatisch · Revision ${input.currentRevision}`;
const conflictingName = database
.select({ id: projectSnapshots.id })
.from(projectSnapshots)
.where(
and(
eq(projectSnapshots.projectId, input.projectId),
eq(projectSnapshots.name, preferredName)
)
)
.get();
const metadata: ProjectSnapshotMetadata = {
id,
projectId: input.projectId,
sourceRevision: input.currentRevision,
schemaVersion: projectStateSnapshotSchemaVersion,
kind: "automatic",
name: conflictingName
? `${preferredName} · ${id}`
: preferredName,
description: `Automatischer Sicherungspunkt nach jeweils ${automaticProjectSnapshotIntervalRevisions} Projektänderungen.`,
payloadSha256: current.payloadSha256,
createdAtIso: input.createdAtIso,
createdByActorId: input.actorId ?? null,
};
database
.insert(projectSnapshots)
.values({
...metadata,
payloadJson: current.payloadJson,
})
.run();
const automaticSnapshots = database
.select({ id: projectSnapshots.id })
.from(projectSnapshots)
.where(
and(
eq(projectSnapshots.projectId, input.projectId),
eq(projectSnapshots.kind, "automatic")
)
)
.orderBy(
desc(projectSnapshots.sourceRevision),
desc(projectSnapshots.createdAtIso),
desc(projectSnapshots.id)
)
.all();
const expiredIds = automaticSnapshots
.slice(automaticProjectSnapshotRetentionCount)
.map((snapshot) => snapshot.id);
if (expiredIds.length) {
database
.delete(projectSnapshots)
.where(inArray(projectSnapshots.id, expiredIds))
.run();
}
return metadata;
}
@@ -1,282 +0,0 @@
import crypto from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import type {
CircuitDeviceRowTransactionStore,
CreateCircuitDeviceRowTransactionInput,
CreateCircuitWithDeviceRowsTransactionInput,
MoveCircuitDeviceRowsTransactionInput,
} from "../../domain/ports/circuit-device-row-transaction.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuits } from "../schema/circuits.js";
import { toCircuitCreateValues } from "./circuit.persistence.js";
import {
toCircuitDeviceRowCreateValues,
} from "./circuit-device-row.persistence.js";
export class CircuitDeviceRowTransactionRepository
implements CircuitDeviceRowTransactionStore
{
constructor(private readonly database: AppDatabase) {}
createInCircuit(input: CreateCircuitDeviceRowTransactionInput) {
const id = crypto.randomUUID();
this.database.transaction((tx) => {
const [circuit] = tx
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, input.circuitId))
.limit(1)
.all();
if (!circuit) {
throw new Error("Der Stromkreis ist ungültig.");
}
const existingRows = tx
.select({ sortOrder: circuitDeviceRows.sortOrder })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, input.circuitId))
.all();
const lastSortOrder = existingRows.reduce(
(highest, row) => Math.max(highest, row.sortOrder),
0
);
const sortOrder = input.sortOrder ?? lastSortOrder + 10;
tx
.insert(circuitDeviceRows)
.values(toCircuitDeviceRowCreateValues(id, { ...input, sortOrder }))
.run();
tx
.update(circuits)
.set({ isReserve: 0 })
.where(eq(circuits.id, input.circuitId))
.run();
});
return id;
}
createCircuitWithDeviceRows(input: CreateCircuitWithDeviceRowsTransactionInput) {
if (input.deviceRows.length === 0) {
throw new Error("Mindestens eine Gerätezeile ist erforderlich.");
}
const circuitId = crypto.randomUUID();
const rowIds = input.deviceRows.map(() => crypto.randomUUID());
this.database.transaction((tx) => {
tx
.insert(circuits)
.values(
toCircuitCreateValues(circuitId, {
...input.circuit,
isReserve: false,
})
)
.run();
for (let index = 0; index < input.deviceRows.length; index += 1) {
const row = input.deviceRows[index];
tx
.insert(circuitDeviceRows)
.values(
toCircuitDeviceRowCreateValues(rowIds[index], {
...row,
circuitId,
sortOrder: row.sortOrder ?? (index + 1) * 10,
})
)
.run();
}
});
return { circuitId, rowIds };
}
deleteFromCircuit(rowId: string, expectedCircuitId: string) {
this.database.transaction((tx) => {
const [row] = tx
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, rowId))
.limit(1)
.all();
if (!row || row.circuitId !== expectedCircuitId) {
throw new Error("Die Gerätezeile wurde vor dem Löschen verändert.");
}
const result = tx
.delete(circuitDeviceRows)
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.circuitId, expectedCircuitId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("Die Gerätezeile konnte nicht gelöscht werden.");
}
const remainingRows = tx
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, expectedCircuitId))
.all();
tx
.update(circuits)
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
.where(eq(circuits.id, expectedCircuitId))
.run();
});
}
moveRows(input: MoveCircuitDeviceRowsTransactionInput) {
if (input.rows.length === 0) {
throw new Error("Keine Gerätezeilen zum Verschieben angegeben.");
}
if (Boolean(input.targetCircuitId) === Boolean(input.createTargetCircuit)) {
throw new Error("Für die Mehrfachverschiebung muss genau ein Ziel angegeben werden.");
}
const rowIds = input.rows.map((row) => row.id);
if (new Set(rowIds).size !== rowIds.length) {
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
}
const createdCircuitId = input.createTargetCircuit ? crypto.randomUUID() : undefined;
const targetCircuitId = input.targetCircuitId ?? createdCircuitId!;
this.database.transaction((tx) => {
const persistedRows = tx
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (persistedRows.length !== input.rows.length) {
throw new Error("Eine oder mehrere Gerätezeilen sind ungültig.");
}
const persistedRowsById = new Map(persistedRows.map((row) => [row.id, row]));
for (const expectedRow of input.rows) {
if (
persistedRowsById.get(expectedRow.id)?.circuitId !==
expectedRow.expectedCircuitId
) {
throw new Error(
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
);
}
}
let targetCircuit: { id: string; circuitListId: string };
if (input.createTargetCircuit) {
tx
.insert(circuits)
.values({
id: targetCircuitId,
circuitListId: input.createTargetCircuit.circuitListId,
sectionId: input.createTargetCircuit.sectionId,
equipmentIdentifier: input.createTargetCircuit.equipmentIdentifier,
displayName: input.createTargetCircuit.displayName,
sortOrder: input.createTargetCircuit.sortOrder,
isReserve: 0,
})
.run();
targetCircuit = {
id: targetCircuitId,
circuitListId: input.createTargetCircuit.circuitListId,
};
} else {
const [persistedTargetCircuit] = tx
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
.from(circuits)
.where(eq(circuits.id, targetCircuitId))
.limit(1)
.all();
if (!persistedTargetCircuit) {
throw new Error("Der Zielstromkreis ist ungültig.");
}
targetCircuit = persistedTargetCircuit;
}
const sourceCircuitIds = [
...new Set(input.rows.map((row) => row.expectedCircuitId)),
];
const sourceCircuits = tx
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
.from(circuits)
.where(inArray(circuits.id, sourceCircuitIds))
.all();
if (sourceCircuits.length !== sourceCircuitIds.length) {
throw new Error("Einer oder mehrere Quellstromkreise sind ungültig.");
}
if (
sourceCircuits.some(
(circuit) => circuit.circuitListId !== targetCircuit.circuitListId
)
) {
throw new Error(
"Alle verschobenen Zeilen müssen zur Stromkreisliste des Ziels gehören."
);
}
const movedRowIdSet = new Set(rowIds);
const retainedTargetRows = tx
.select({ id: circuitDeviceRows.id, sortOrder: circuitDeviceRows.sortOrder })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, targetCircuitId))
.all()
.filter((row) => !movedRowIdSet.has(row.id));
const lastTargetSortOrder = retainedTargetRows.reduce(
(highest, row) => Math.max(highest, row.sortOrder),
0
);
for (let index = 0; index < input.rows.length; index += 1) {
const row = input.rows[index];
const result = tx
.update(circuitDeviceRows)
.set({
circuitId: targetCircuitId,
sortOrder: lastTargetSortOrder + (index + 1) * 10,
})
.where(
and(
eq(circuitDeviceRows.id, row.id),
eq(circuitDeviceRows.circuitId, row.expectedCircuitId)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
);
}
}
for (const sourceCircuitId of sourceCircuitIds) {
const remainingRows = tx
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, sourceCircuitId))
.all();
tx
.update(circuits)
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
.where(eq(circuits.id, sourceCircuitId))
.run();
}
tx
.update(circuits)
.set({ isReserve: 0 })
.where(eq(circuits.id, targetCircuitId))
.run();
});
return {
movedRowIds: rowIds,
targetCircuitId,
createdCircuitId,
};
}
}
@@ -1,8 +1,18 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitSectionReorderProjectCommand,
circuitSectionReorderCommandType,
createCircuitSectionReorderProjectCommand,
type CircuitSectionReorderAssignment,
type CircuitSectionReorderProjectCommand,
} from "../../domain/models/circuit-section-reorder-project-command.model.js";
import {
assertCircuitSectionsReorderProjectCommand,
circuitSectionsReorderCommandType,
createCircuitSectionsReorderProjectCommand,
type CircuitSectionsReorderProjectCommand,
type CircuitSectionsReorderSection,
} from "../../domain/models/circuit-sections-reorder-project-command.model.js";
import type {
CircuitSectionReorderProjectCommandStore,
ExecuteCircuitSectionReorderCommandInput,
@@ -14,106 +24,49 @@ import { circuits } from "../schema/circuits.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type ReorderHistoryCommand =
| CircuitSectionReorderProjectCommand
| CircuitSectionsReorderProjectCommand;
export class CircuitSectionReorderProjectCommandRepository
implements CircuitSectionReorderProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitSectionReorderCommandInput) {
assertCircuitSectionReorderProjectCommand(input.command);
this.assertSupportedCommand(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 commandSections = this.getCommandSections(input.command);
const inverseSections = commandSections.map((section) => {
this.assertCompleteCurrentSection(
tx,
input.projectId,
section
);
return {
sectionId: section.sectionId,
assignments: section.assignments.map((assignment) => ({
circuitId: assignment.circuitId,
expectedSortOrder: assignment.targetSortOrder,
targetSortOrder: assignment.expectedSortOrder,
})),
};
});
for (const section of commandSections) {
this.applyAssignments(tx, section);
}
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
)
const inverse =
input.command.type === circuitSectionReorderCommandType
? createCircuitSectionReorderProjectCommand(
inverseSections[0].sectionId,
inverseSections[0].assignments
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during section reorder execution."
);
}
}
: createCircuitSectionsReorderProjectCommand(
inverseSections
);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
@@ -132,4 +85,125 @@ export class CircuitSectionReorderProjectCommandRepository
return { revision, inverse };
});
}
private assertSupportedCommand(
command: ReorderHistoryCommand
) {
if (command.type === circuitSectionReorderCommandType) {
assertCircuitSectionReorderProjectCommand(command);
return;
}
if (command.type === circuitSectionsReorderCommandType) {
assertCircuitSectionsReorderProjectCommand(command);
return;
}
throw new Error("Unsupported circuit section reorder command.");
}
private getCommandSections(
command: ReorderHistoryCommand
): CircuitSectionsReorderSection[] {
if (command.type === circuitSectionReorderCommandType) {
return [
{
sectionId: command.payload.sectionId,
assignments: command.payload.assignments,
},
];
}
return command.payload.sections;
}
private assertCompleteCurrentSection(
database: AppDatabase,
projectId: string,
sectionCommand: CircuitSectionsReorderSection
) {
const section = database
.select({
id: circuitSections.id,
circuitListId: circuitSections.circuitListId,
projectId: circuitLists.projectId,
})
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(eq(circuitSections.id, sectionCommand.sectionId))
.get();
if (!section || section.projectId !== projectId) {
throw new Error(
"Circuit section does not belong to project."
);
}
const persistedCircuits = database
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
sortOrder: circuits.sortOrder,
})
.from(circuits)
.where(eq(circuits.sectionId, section.id))
.all();
const assignments = sectionCommand.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."
);
}
}
}
private applyAssignments(
database: AppDatabase,
sectionCommand: {
sectionId: string;
assignments: CircuitSectionReorderAssignment[];
}
) {
for (const assignment of sectionCommand.assignments) {
if (
assignment.expectedSortOrder === assignment.targetSortOrder
) {
continue;
}
const updated = database
.update(circuits)
.set({ sortOrder: assignment.targetSortOrder })
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, sectionCommand.sectionId),
eq(circuits.sortOrder, assignment.expectedSortOrder)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during section reorder execution."
);
}
}
}
}
@@ -1,116 +0,0 @@
import { and, eq, inArray } from "drizzle-orm";
import type { CircuitSectionTransactionStore } from "../../domain/ports/circuit-section-transaction.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuits } from "../schema/circuits.js";
export class CircuitSectionTransactionRepository
implements CircuitSectionTransactionStore
{
constructor(private readonly database: AppDatabase) {}
updateSortOrders(sectionId: string, orderedCircuitIds: string[]) {
if (orderedCircuitIds.length === 0) {
return;
}
if (new Set(orderedCircuitIds).size !== orderedCircuitIds.length) {
throw new Error(
"Stromkreise dürfen in der Reihenfolge nicht mehrfach vorkommen."
);
}
this.database.transaction((tx) => {
const sectionCircuits = tx
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.sectionId, sectionId))
.all();
const sectionCircuitIds = new Set(
sectionCircuits.map((circuit) => circuit.id)
);
if (
sectionCircuits.length !== orderedCircuitIds.length ||
orderedCircuitIds.some((circuitId) => !sectionCircuitIds.has(circuitId))
) {
throw new Error(
"Die Reihenfolge muss alle Stromkreise des Bereichs enthalten."
);
}
for (let index = 0; index < orderedCircuitIds.length; index += 1) {
const result = tx
.update(circuits)
.set({ sortOrder: (index + 1) * 10 })
.where(
and(
eq(circuits.sectionId, sectionId),
eq(circuits.id, orderedCircuitIds[index])
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"Ein Stromkreis wurde vor Abschluss der Sortierung verändert."
);
}
}
});
}
updateEquipmentIdentifiers(
circuitListId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>,
tempNamespace: string
) {
if (updates.length === 0) {
return;
}
this.database.transaction((tx) => {
const ids = updates.map((entry) => entry.id);
const existing = tx
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, circuitListId),
inArray(circuits.id, ids)
)
)
.all();
if (existing.length !== ids.length) {
throw new Error(
"One or more circuit ids are invalid for circuit list."
);
}
const stamp = Date.now();
for (let index = 0; index < updates.length; index += 1) {
const entry = updates[index];
const tempIdentifier = `__tmp_renumber_${tempNamespace}_${stamp}_${index}`;
tx
.update(circuits)
.set({ equipmentIdentifier: tempIdentifier })
.where(
and(
eq(circuits.circuitListId, circuitListId),
eq(circuits.id, entry.id)
)
)
.run();
}
for (const entry of updates) {
tx
.update(circuits)
.set({ equipmentIdentifier: entry.equipmentIdentifier })
.where(
and(
eq(circuits.circuitListId, circuitListId),
eq(circuits.id, entry.id)
)
)
.run();
}
});
}
}
@@ -1,15 +1,11 @@
import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm";
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
import { db } from "../client.js";
import { circuitSections } from "../schema/circuit-sections.js";
export function createDefaultCircuitSectionValues(circuitListId: string) {
return [
{ key: "lighting", displayName: "Lighting", prefix: "-1F", sortOrder: 10 },
{ key: "single_phase", displayName: "Single-phase circuits", prefix: "-2F", sortOrder: 20 },
{ key: "three_phase", displayName: "Three-phase circuits", prefix: "-3F", sortOrder: 30 },
{ key: "unassigned", displayName: "Unassigned", prefix: "-UF", sortOrder: 90 },
].map((entry) => ({
return defaultCircuitSectionDefinitions.map((entry) => ({
id: crypto.randomUUID(),
circuitListId,
...entry,
@@ -0,0 +1,238 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
createDistributionBoardDeleteProjectCommand,
createDistributionBoardInsertProjectCommand,
distributionBoardDeleteCommandType,
distributionBoardInsertCommandType,
type DistributionBoardStructureProjectCommand,
type DistributionBoardStructureSnapshot,
} from "../../domain/models/distribution-board-structure-project-command.model.js";
import type {
DistributionBoardStructureProjectCommandStore,
ExecuteDistributionBoardStructureCommandInput,
} from "../../domain/ports/distribution-board-structure-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 { distributionBoards } from "../schema/distribution-boards.js";
import { projects } from "../schema/projects.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class DistributionBoardStructureProjectCommandRepository
implements DistributionBoardStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteDistributionBoardStructureCommandInput) {
return this.database.transaction((tx) => {
const inverse = this.applyCommand(
tx,
input.projectId,
input.command
);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: DistributionBoardStructureProjectCommand
): DistributionBoardStructureProjectCommand {
if (command.type === distributionBoardInsertCommandType) {
assertDistributionBoardInsertProjectCommand(command);
return this.insert(
database,
projectId,
command.payload.structure
);
}
if (command.type === distributionBoardDeleteCommandType) {
assertDistributionBoardDeleteProjectCommand(command);
return this.delete(
database,
projectId,
command.payload.structure
);
}
throw new Error("Unsupported distribution-board structure command.");
}
private insert(
database: AppDatabase,
projectId: string,
structure: DistributionBoardStructureSnapshot
) {
if (
structure.distributionBoard.projectId !== projectId ||
structure.circuitList.projectId !== projectId
) {
throw new Error(
"Distribution-board structure does not belong to project."
);
}
const project = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
const existingBoard = database
.select({ id: distributionBoards.id })
.from(distributionBoards)
.where(eq(distributionBoards.id, structure.distributionBoard.id))
.get();
const existingList = database
.select({ id: circuitLists.id })
.from(circuitLists)
.where(eq(circuitLists.id, structure.circuitList.id))
.get();
const existingSection = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(
inArray(
circuitSections.id,
structure.sections.map((section) => section.id)
)
)
.limit(1)
.get();
if (existingBoard || existingList || existingSection) {
throw new Error("Distribution-board structure id already exists.");
}
database
.insert(distributionBoards)
.values(structure.distributionBoard)
.run();
database.insert(circuitLists).values(structure.circuitList).run();
database.insert(circuitSections).values(structure.sections).run();
return createDistributionBoardDeleteProjectCommand(structure);
}
private delete(
database: AppDatabase,
projectId: string,
structure: DistributionBoardStructureSnapshot
) {
const board = database
.select()
.from(distributionBoards)
.where(eq(distributionBoards.id, structure.distributionBoard.id))
.get();
const list = database
.select()
.from(circuitLists)
.where(eq(circuitLists.id, structure.circuitList.id))
.get();
const sections = database
.select()
.from(circuitSections)
.where(
eq(circuitSections.circuitListId, structure.circuitList.id)
)
.orderBy(
asc(circuitSections.sortOrder),
asc(circuitSections.id)
)
.all();
if (
!board ||
!list ||
board.projectId !== projectId ||
!structureMatches(structure, { board, list, sections })
) {
throw new Error(
"Distribution-board structure changed before deletion."
);
}
const populatedCircuit = database
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.circuitListId, structure.circuitList.id))
.limit(1)
.get();
if (populatedCircuit) {
throw new Error(
"A populated distribution board cannot be removed by history."
);
}
const deleted = database
.delete(distributionBoards)
.where(
and(
eq(
distributionBoards.id,
structure.distributionBoard.id
),
eq(distributionBoards.projectId, projectId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Distribution board could not be deleted.");
}
return createDistributionBoardInsertProjectCommand(structure);
}
}
function structureMatches(
expected: DistributionBoardStructureSnapshot,
actual: {
board: typeof distributionBoards.$inferSelect;
list: typeof circuitLists.$inferSelect;
sections: Array<typeof circuitSections.$inferSelect>;
}
) {
if (
!sameRecord(expected.distributionBoard, actual.board) ||
!sameRecord(expected.circuitList, actual.list) ||
expected.sections.length !== actual.sections.length
) {
return false;
}
const expectedSections = [...expected.sections].sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
);
return expectedSections.every((section, index) =>
sameRecord(section, actual.sections[index])
);
}
function sameRecord(
expected: Record<string, unknown>,
actual: Record<string, unknown>
) {
const expectedEntries = Object.entries(expected);
return (
expectedEntries.length === Object.keys(actual).length &&
expectedEntries.every(
([key, value]) => actual[key] === value
)
);
}
@@ -14,7 +14,7 @@ export function applyProjectHistoryTransition(
database: AppDatabase,
input: ApplyProjectHistoryTransitionInput
) {
if (input.source === "user") {
if (input.source === "user" || input.source === "restore") {
database
.delete(projectHistoryStackEntries)
.where(
@@ -1,11 +1,14 @@
import { and, asc, desc, eq } from "drizzle-orm";
import { and, asc, desc, eq, lt } from "drizzle-orm";
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
import type {
ProjectHistoryCommand,
ProjectHistoryDirection,
ListProjectRevisionsInput,
ProjectRevisionPage,
ProjectHistoryState,
ProjectHistoryStore,
} from "../../domain/ports/project-history.store.js";
import type { ProjectRevisionSource } from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
@@ -47,6 +50,74 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
};
}
listRevisions(
input: ListProjectRevisionsInput
): ProjectRevisionPage | null {
validateRevisionPageInput(input);
const project = this.database
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
return null;
}
const projectCondition = eq(
projectRevisions.projectId,
input.projectId
);
const rows = this.database
.select({
revisionId: projectRevisions.id,
changeSetId: projectChangeSets.id,
revisionNumber: projectRevisions.revisionNumber,
createdAtIso: projectRevisions.createdAtIso,
actorId: projectRevisions.actorId,
source: projectRevisions.source,
description: projectRevisions.description,
commandType: projectChangeSets.commandType,
payloadSchemaVersion: projectChangeSets.payloadSchemaVersion,
})
.from(projectRevisions)
.innerJoin(
projectChangeSets,
eq(
projectChangeSets.projectRevisionId,
projectRevisions.id
)
)
.where(
input.beforeRevision === undefined
? projectCondition
: and(
projectCondition,
lt(
projectRevisions.revisionNumber,
input.beforeRevision
)
)
)
.orderBy(desc(projectRevisions.revisionNumber))
.limit(input.limit + 1)
.all();
const hasMore = rows.length > input.limit;
const revisions = rows.slice(0, input.limit).map((row) => ({
...row,
source: row.source as ProjectRevisionSource,
}));
return {
projectId: input.projectId,
currentRevision: project.currentRevision,
revisions,
nextBeforeRevision:
hasMore && revisions.length > 0
? revisions.at(-1)!.revisionNumber
: null,
};
}
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
@@ -93,3 +164,22 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
};
}
}
function validateRevisionPageInput(input: ListProjectRevisionsInput) {
if (
!Number.isSafeInteger(input.limit) ||
input.limit < 1 ||
input.limit > 100
) {
throw new Error("Project revision page limit must be between 1 and 100.");
}
if (
input.beforeRevision !== undefined &&
(!Number.isSafeInteger(input.beforeRevision) ||
input.beforeRevision < 1)
) {
throw new Error(
"Project revision cursor must be a positive integer."
);
}
}
@@ -10,6 +10,7 @@ import type { AppDatabase } from "../database-context.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projectRevisions } from "../schema/project-revisions.js";
import { projects } from "../schema/projects.js";
import { captureAutomaticProjectSnapshot } from "./automatic-project-snapshot.persistence.js";
export function appendProjectRevision(
database: AppDatabase,
@@ -73,6 +74,13 @@ export function appendProjectRevision(
})
.run();
captureAutomaticProjectSnapshot(database, {
projectId: input.projectId,
currentRevision: revisionNumber,
createdAtIso,
actorId: input.actorId,
});
return {
revisionId,
changeSetId,
@@ -0,0 +1,71 @@
import { eq } from "drizzle-orm";
import {
assertProjectSettingsUpdateProjectCommand,
createProjectSettingsUpdateProjectCommand,
} from "../../domain/models/project-settings-project-command.model.js";
import type {
ExecuteProjectSettingsUpdateCommandInput,
ProjectSettingsProjectCommandStore,
} from "../../domain/ports/project-settings-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { projects } from "../schema/projects.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class ProjectSettingsProjectCommandRepository
implements ProjectSettingsProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
executeUpdate(input: ExecuteProjectSettingsUpdateCommandInput) {
assertProjectSettingsUpdateProjectCommand(input.command);
return this.database.transaction((tx) => {
const current = tx
.select()
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!current) {
throw new Error("Project not found.");
}
if (
current.singlePhaseVoltageV ===
input.command.payload.singlePhaseVoltageV &&
current.threePhaseVoltageV ===
input.command.payload.threePhaseVoltageV
) {
throw new Error("Project settings did not change.");
}
const inverse = createProjectSettingsUpdateProjectCommand({
singlePhaseVoltageV: current.singlePhaseVoltageV,
threePhaseVoltageV: current.threePhaseVoltageV,
});
const updated = tx
.update(projects)
.set(input.command.payload)
.where(eq(projects.id, input.projectId))
.run();
if (updated.changes !== 1) {
throw new Error("Project changed before settings update.");
}
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 };
});
}
}
@@ -0,0 +1,191 @@
import crypto from "node:crypto";
import { and, desc, eq } from "drizzle-orm";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import {
deserializeProjectStateSnapshot,
projectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
CreateNamedProjectSnapshotInput,
PreparedProjectSnapshotRestore,
ProjectSnapshotMetadata,
ProjectSnapshotStore,
} from "../../domain/ports/project-snapshot.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectSnapshots } from "../schema/project-snapshots.js";
import { projects } from "../schema/projects.js";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
} from "./project-state-snapshot.persistence.js";
export class ProjectSnapshotRepository implements ProjectSnapshotStore {
constructor(private readonly database: AppDatabase) {}
createNamed(
input: CreateNamedProjectSnapshotInput
): ProjectSnapshotMetadata | null {
validateCreateInput(input);
const snapshotName = input.name.trim();
const snapshotDescription =
input.description?.trim() || null;
return this.database.transaction((tx) => {
const current = readProjectStateSnapshot(tx, input.projectId);
if (!current) {
return null;
}
if (current.currentRevision !== input.expectedRevision) {
throw new ProjectRevisionConflictError(
input.projectId,
input.expectedRevision,
current.currentRevision
);
}
const existing = tx
.select({ id: projectSnapshots.id })
.from(projectSnapshots)
.where(
and(
eq(projectSnapshots.projectId, input.projectId),
eq(projectSnapshots.name, snapshotName)
)
)
.get();
if (existing) {
throw new ProjectSnapshotNameConflictError(
input.projectId,
snapshotName
);
}
const metadata: ProjectSnapshotMetadata = {
id: crypto.randomUUID(),
projectId: input.projectId,
sourceRevision: current.currentRevision,
schemaVersion: projectStateSnapshotSchemaVersion,
kind: "named",
name: snapshotName,
description: snapshotDescription,
payloadSha256: current.payloadSha256,
createdAtIso: new Date().toISOString(),
createdByActorId: input.actorId ?? null,
};
tx.insert(projectSnapshots)
.values({
...metadata,
payloadJson: current.payloadJson,
})
.run();
return metadata;
});
}
listByProject(projectId: string): ProjectSnapshotMetadata[] | null {
const project = this.database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
return null;
}
return this.database
.select({
id: projectSnapshots.id,
projectId: projectSnapshots.projectId,
sourceRevision: projectSnapshots.sourceRevision,
schemaVersion: projectSnapshots.schemaVersion,
kind: projectSnapshots.kind,
name: projectSnapshots.name,
description: projectSnapshots.description,
payloadSha256: projectSnapshots.payloadSha256,
createdAtIso: projectSnapshots.createdAtIso,
createdByActorId: projectSnapshots.createdByActorId,
})
.from(projectSnapshots)
.where(eq(projectSnapshots.projectId, projectId))
.orderBy(
desc(projectSnapshots.createdAtIso),
desc(projectSnapshots.id)
)
.all();
}
prepareRestore(
projectId: string,
snapshotId: string
): PreparedProjectSnapshotRestore | null {
const current = readProjectStateSnapshot(this.database, projectId);
if (!current) {
return null;
}
const stored = this.database
.select()
.from(projectSnapshots)
.where(
and(
eq(projectSnapshots.id, snapshotId),
eq(projectSnapshots.projectId, projectId)
)
)
.get();
if (!stored) {
return null;
}
const actualChecksum = hashProjectStatePayload(stored.payloadJson);
if (actualChecksum !== stored.payloadSha256) {
throw new Error("Project snapshot checksum verification failed.");
}
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) {
throw new Error("Project snapshot schema version is not supported.");
}
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);
return {
snapshot: {
id: stored.id,
projectId: stored.projectId,
sourceRevision: stored.sourceRevision,
schemaVersion: stored.schemaVersion,
kind: stored.kind,
name: stored.name,
description: stored.description,
payloadSha256: stored.payloadSha256,
createdAtIso: stored.createdAtIso,
createdByActorId: stored.createdByActorId,
},
command: createProjectStateRestoreCommand(
current.payloadSha256,
targetState
),
};
}
}
function validateCreateInput(input: CreateNamedProjectSnapshotInput) {
if (
!Number.isSafeInteger(input.expectedRevision) ||
input.expectedRevision < 0
) {
throw new Error(
"Expected project revision must be a non-negative integer."
);
}
if (!input.name.trim()) {
throw new Error("Project snapshot name is required.");
}
if (input.name.trim().length > 100) {
throw new Error(
"Project snapshot name must not exceed 100 characters."
);
}
if (
input.description !== undefined &&
input.description.trim().length > 500
) {
throw new Error(
"Project snapshot description must not exceed 500 characters."
);
}
}
@@ -0,0 +1,290 @@
import { eq } from "drizzle-orm";
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
import {
assertProjectStateRestoreCommand,
createProjectStateRestoreCommand,
} from "../../domain/models/project-state-restore-command.model.js";
import {
serializeProjectStateSnapshot,
type ProjectStateSnapshot,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
ExecuteProjectStateRestoreCommandInput,
ProjectStateRestoreCommandStore,
} from "../../domain/ports/project-state-restore-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { consumers } from "../schema/consumers.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
} from "./project-state-snapshot.persistence.js";
export class ProjectStateRestoreCommandRepository
implements ProjectStateRestoreCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectStateRestoreCommandInput) {
assertProjectStateRestoreCommand(input.command);
if (input.command.payload.targetState.project.id !== input.projectId) {
throw new Error("Restore state belongs to a different project.");
}
return this.database.transaction((tx) => {
const current = readProjectStateSnapshot(tx, input.projectId);
if (!current) {
throw new Error("Project not found.");
}
if (
current.payloadSha256 !==
input.command.payload.expectedStateSha256
) {
throw new ProjectStateConflictError(
input.projectId,
input.command.payload.expectedStateSha256,
current.payloadSha256
);
}
const targetPayloadJson = serializeProjectStateSnapshot(
input.command.payload.targetState
);
const inverse = createProjectStateRestoreCommand(
hashProjectStatePayload(targetPayloadJson),
current.state
);
replaceProjectState(tx, input.command.payload.targetState);
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 replaceProjectState(
database: AppDatabase,
state: ProjectStateSnapshot
) {
const upgradeOnlyData = captureUpgradeOnlyData(
database,
state.project.id
);
database
.delete(distributionBoards)
.where(eq(distributionBoards.projectId, state.project.id))
.run();
database
.delete(projectDevices)
.where(eq(projectDevices.projectId, state.project.id))
.run();
database
.delete(rooms)
.where(eq(rooms.projectId, state.project.id))
.run();
database
.delete(floors)
.where(eq(floors.projectId, state.project.id))
.run();
database
.update(projects)
.set({
name: state.project.name,
singlePhaseVoltageV: state.project.singlePhaseVoltageV,
threePhaseVoltageV: state.project.threePhaseVoltageV,
})
.where(eq(projects.id, state.project.id))
.run();
insertMany(database, floors, state.floors);
insertMany(database, rooms, state.rooms);
insertMany(database, projectDevices, state.projectDevices);
insertMany(database, distributionBoards, state.distributionBoards);
insertMany(database, circuitLists, state.circuitLists);
insertMany(database, circuitSections, state.circuitSections);
insertMany(
database,
circuits,
state.circuits.map(({ deviceRows: _deviceRows, ...circuit }) => ({
...circuit,
isReserve: circuit.isReserve ? 1 : 0,
}))
);
insertMany(
database,
circuitDeviceRows,
state.circuits.flatMap((circuit) => circuit.deviceRows)
);
restoreCompatibleUpgradeOnlyData(database, state, upgradeOnlyData);
}
function captureUpgradeOnlyData(
database: AppDatabase,
projectId: string
) {
const consumerLinks = database
.select({
id: consumers.id,
distributionBoardId: consumers.distributionBoardId,
circuitListId: consumers.circuitListId,
projectDeviceId: consumers.projectDeviceId,
roomId: consumers.roomId,
})
.from(consumers)
.where(eq(consumers.projectId, projectId))
.all();
const migrationMappings = database
.select({
consumerId: legacyConsumerCircuitMigrations.consumerId,
circuitId: legacyConsumerCircuitMigrations.circuitId,
circuitDeviceRowId:
legacyConsumerCircuitMigrations.circuitDeviceRowId,
circuitListId: legacyConsumerCircuitMigrations.circuitListId,
createdAtIso: legacyConsumerCircuitMigrations.createdAtIso,
})
.from(legacyConsumerCircuitMigrations)
.innerJoin(
circuitLists,
eq(
circuitLists.id,
legacyConsumerCircuitMigrations.circuitListId
)
)
.where(eq(circuitLists.projectId, projectId))
.all();
const migrationReports = database
.select({
id: legacyConsumerMigrationReports.id,
circuitListId: legacyConsumerMigrationReports.circuitListId,
legacyConsumerCount:
legacyConsumerMigrationReports.legacyConsumerCount,
createdCircuitCount:
legacyConsumerMigrationReports.createdCircuitCount,
createdDeviceRowCount:
legacyConsumerMigrationReports.createdDeviceRowCount,
duplicateGroupedCount:
legacyConsumerMigrationReports.duplicateGroupedCount,
generatedIdentifierCount:
legacyConsumerMigrationReports.generatedIdentifierCount,
unassignedRowCount:
legacyConsumerMigrationReports.unassignedRowCount,
warningsJson: legacyConsumerMigrationReports.warningsJson,
generatedIdentifiersJson:
legacyConsumerMigrationReports.generatedIdentifiersJson,
duplicateGroupsJson:
legacyConsumerMigrationReports.duplicateGroupsJson,
createdAtIso: legacyConsumerMigrationReports.createdAtIso,
})
.from(legacyConsumerMigrationReports)
.innerJoin(
circuitLists,
eq(circuitLists.id, legacyConsumerMigrationReports.circuitListId)
)
.where(eq(circuitLists.projectId, projectId))
.all();
return { consumerLinks, migrationMappings, migrationReports };
}
function restoreCompatibleUpgradeOnlyData(
database: AppDatabase,
state: ProjectStateSnapshot,
data: ReturnType<typeof captureUpgradeOnlyData>
) {
const boardIds = new Set(
state.distributionBoards.map((entry) => entry.id)
);
const listIds = new Set(state.circuitLists.map((entry) => entry.id));
const deviceIds = new Set(
state.projectDevices.map((entry) => entry.id)
);
const roomIds = new Set(state.rooms.map((entry) => entry.id));
const circuitIds = new Set(state.circuits.map((entry) => entry.id));
const rowIds = new Set(
state.circuits.flatMap((circuit) =>
circuit.deviceRows.map((entry) => entry.id)
)
);
for (const link of data.consumerLinks) {
database
.update(consumers)
.set({
distributionBoardId:
link.distributionBoardId &&
boardIds.has(link.distributionBoardId)
? link.distributionBoardId
: null,
circuitListId:
link.circuitListId && listIds.has(link.circuitListId)
? link.circuitListId
: null,
projectDeviceId:
link.projectDeviceId && deviceIds.has(link.projectDeviceId)
? link.projectDeviceId
: null,
roomId:
link.roomId && roomIds.has(link.roomId) ? link.roomId : null,
})
.where(eq(consumers.id, link.id))
.run();
}
insertMany(
database,
legacyConsumerCircuitMigrations,
data.migrationMappings.filter(
(entry) =>
listIds.has(entry.circuitListId) &&
circuitIds.has(entry.circuitId) &&
rowIds.has(entry.circuitDeviceRowId)
)
);
insertMany(
database,
legacyConsumerMigrationReports,
data.migrationReports.filter((entry) =>
listIds.has(entry.circuitListId)
)
);
}
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
database: AppDatabase,
table: TTable,
values: unknown[]
) {
if (values.length === 0) {
return;
}
database
.insert(table)
.values(values as never)
.run();
}
@@ -0,0 +1,201 @@
import crypto from "node:crypto";
import { asc, eq } from "drizzle-orm";
import {
parseProjectStateSnapshot,
projectStateSnapshotSchemaVersion,
serializeProjectStateSnapshot,
type ProjectStateSnapshot,
} from "../../domain/models/project-state-snapshot.model.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
export interface PersistedProjectStateSnapshot {
currentRevision: number;
state: ProjectStateSnapshot;
payloadJson: string;
payloadSha256: string;
}
export function readProjectStateSnapshot(
database: AppDatabase,
projectId: string
): PersistedProjectStateSnapshot | null {
const project = database
.select({
id: projects.id,
name: projects.name,
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
currentRevision: projects.currentRevision,
})
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
return null;
}
const boardRows = database
.select()
.from(distributionBoards)
.where(eq(distributionBoards.projectId, projectId))
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
.all();
const listRows = database
.select()
.from(circuitLists)
.where(eq(circuitLists.projectId, projectId))
.orderBy(asc(circuitLists.name), asc(circuitLists.id))
.all();
const sectionRows = database
.select({
id: circuitSections.id,
circuitListId: circuitSections.circuitListId,
key: circuitSections.key,
displayName: circuitSections.displayName,
prefix: circuitSections.prefix,
sortOrder: circuitSections.sortOrder,
})
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(eq(circuitLists.projectId, projectId))
.orderBy(
asc(circuitSections.circuitListId),
asc(circuitSections.sortOrder),
asc(circuitSections.id)
)
.all();
const circuitRows = database
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
sectionId: circuits.sectionId,
equipmentIdentifier: circuits.equipmentIdentifier,
displayName: circuits.displayName,
sortOrder: circuits.sortOrder,
protectionType: circuits.protectionType,
protectionRatedCurrent: circuits.protectionRatedCurrent,
protectionCharacteristic: circuits.protectionCharacteristic,
cableType: circuits.cableType,
cableCrossSection: circuits.cableCrossSection,
cableLength: circuits.cableLength,
rcdAssignment: circuits.rcdAssignment,
terminalDesignation: circuits.terminalDesignation,
voltage: circuits.voltage,
controlRequirement: circuits.controlRequirement,
status: circuits.status,
isReserve: circuits.isReserve,
remark: circuits.remark,
})
.from(circuits)
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.orderBy(
asc(circuits.circuitListId),
asc(circuits.sortOrder),
asc(circuits.id)
)
.all();
const deviceRowRows = database
.select({
id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId,
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
sortOrder: circuitDeviceRows.sortOrder,
name: circuitDeviceRows.name,
displayName: circuitDeviceRows.displayName,
phaseType: circuitDeviceRows.phaseType,
connectionKind: circuitDeviceRows.connectionKind,
costGroup: circuitDeviceRows.costGroup,
category: circuitDeviceRows.category,
level: circuitDeviceRows.level,
roomId: circuitDeviceRows.roomId,
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
quantity: circuitDeviceRows.quantity,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi,
remark: circuitDeviceRows.remark,
overriddenFields: circuitDeviceRows.overriddenFields,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.orderBy(
asc(circuitDeviceRows.circuitId),
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
const projectDeviceRows = database
.select()
.from(projectDevices)
.where(eq(projectDevices.projectId, projectId))
.orderBy(asc(projectDevices.displayName), asc(projectDevices.id))
.all();
const floorRows = database
.select()
.from(floors)
.where(eq(floors.projectId, projectId))
.orderBy(asc(floors.sortOrder), asc(floors.id))
.all();
const roomRows = database
.select()
.from(rooms)
.where(eq(rooms.projectId, projectId))
.orderBy(asc(rooms.roomNumber), asc(rooms.id))
.all();
const deviceRowsByCircuit = new Map<string, typeof deviceRowRows>();
for (const row of deviceRowRows) {
const entries = deviceRowsByCircuit.get(row.circuitId) ?? [];
entries.push(row);
deviceRowsByCircuit.set(row.circuitId, entries);
}
const state = parseProjectStateSnapshot({
schemaVersion: projectStateSnapshotSchemaVersion,
project: {
id: project.id,
name: project.name,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
},
distributionBoards: boardRows,
circuitLists: listRows,
circuitSections: sectionRows,
circuits: circuitRows.map((circuit) => ({
...circuit,
isReserve: Boolean(circuit.isReserve),
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
})),
projectDevices: projectDeviceRows,
floors: floorRows,
rooms: roomRows,
});
const payloadJson = serializeProjectStateSnapshot(state);
return {
currentRevision: project.currentRevision,
state,
payloadJson,
payloadSha256: hashProjectStatePayload(payloadJson),
};
}
export function hashProjectStatePayload(payloadJson: string) {
return crypto.createHash("sha256").update(payloadJson).digest("hex");
}
-11
View File
@@ -4,7 +4,6 @@ import { db } from "../client.js";
import { projects } from "../schema/projects.js";
import type {
CreateProjectInput,
UpdateProjectSettingsInput,
} from "../../shared/validation/project-structure.schemas.js";
export class ProjectRepository {
@@ -30,16 +29,6 @@ export class ProjectRepository {
return row ?? null;
}
async updateSettings(projectId: string, input: UpdateProjectSettingsInput) {
await db
.update(projects)
.set({
singlePhaseVoltageV: input.singlePhaseVoltageV,
threePhaseVoltageV: input.threePhaseVoltageV,
})
.where(eq(projects.id, projectId));
}
async delete(projectId: string) {
await db.delete(projects).where(eq(projects.id, projectId));
}
+45
View File
@@ -0,0 +1,45 @@
import {
index,
integer,
sqliteTable,
text,
unique,
} from "drizzle-orm/sqlite-core";
import { projectSnapshotKinds } from "../../domain/models/project-snapshot-policy.model.js";
import { projects } from "./projects.js";
export const projectSnapshots = sqliteTable(
"project_snapshots",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
sourceRevision: integer("source_revision").notNull(),
schemaVersion: integer("schema_version").notNull(),
kind: text("kind", { enum: projectSnapshotKinds })
.notNull()
.default("named"),
name: text("name").notNull(),
description: text("description"),
payloadJson: text("payload_json").notNull(),
payloadSha256: text("payload_sha256").notNull(),
createdAtIso: text("created_at_iso").notNull(),
createdByActorId: text("created_by_actor_id"),
},
(table) => [
unique("project_snapshots_project_name_unique").on(
table.projectId,
table.name
),
index("project_snapshots_project_created_idx").on(
table.projectId,
table.createdAtIso
),
index("project_snapshots_project_kind_revision_idx").on(
table.projectId,
table.kind,
table.sourceRevision
),
]
);
@@ -0,0 +1,11 @@
export class ProjectSnapshotNameConflictError extends Error {
constructor(
readonly projectId: string,
readonly snapshotName: string
) {
super(
`Project ${projectId} already has a snapshot named "${snapshotName}".`
);
this.name = "ProjectSnapshotNameConflictError";
}
}
@@ -0,0 +1,10 @@
export class ProjectStateConflictError extends Error {
constructor(
public readonly projectId: string,
public readonly expectedSha256: string,
public readonly actualSha256: string
) {
super(`Project ${projectId} changed outside the prepared restore state.`);
this.name = "ProjectStateConflictError";
}
}
@@ -0,0 +1,94 @@
import {
assertCircuitSectionReorderProjectCommand,
circuitSectionReorderCommandSchemaVersion,
type CircuitSectionReorderAssignment,
} from "./circuit-section-reorder-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitSectionsReorderCommandType =
"circuit.reorder-sections" as const;
export const circuitSectionsReorderCommandSchemaVersion =
circuitSectionReorderCommandSchemaVersion;
export interface CircuitSectionsReorderSection {
sectionId: string;
assignments: CircuitSectionReorderAssignment[];
}
export interface CircuitSectionsReorderCommandPayload {
sections: CircuitSectionsReorderSection[];
}
export interface CircuitSectionsReorderProjectCommand
extends SerializedProjectCommand<CircuitSectionsReorderCommandPayload> {
schemaVersion: typeof circuitSectionsReorderCommandSchemaVersion;
type: typeof circuitSectionsReorderCommandType;
}
export function createCircuitSectionsReorderProjectCommand(
sections: CircuitSectionsReorderSection[]
): CircuitSectionsReorderProjectCommand {
const command: CircuitSectionsReorderProjectCommand = {
schemaVersion: circuitSectionsReorderCommandSchemaVersion,
type: circuitSectionsReorderCommandType,
payload: { sections },
};
assertCircuitSectionsReorderProjectCommand(command);
return command;
}
export function assertCircuitSectionsReorderProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitSectionsReorderProjectCommand {
if (
command.schemaVersion !==
circuitSectionsReorderCommandSchemaVersion ||
command.type !== circuitSectionsReorderCommandType
) {
throw new Error("Unsupported circuit sections reorder command.");
}
if (!isPlainObject(command.payload)) {
throw new Error(
"Circuit sections reorder payload must be an object."
);
}
const sections = command.payload.sections;
if (!Array.isArray(sections) || sections.length === 0) {
throw new Error(
"Circuit sections reorder command requires sections."
);
}
const sectionIds = new Set<string>();
for (const section of sections) {
if (!isPlainObject(section)) {
throw new Error(
"Circuit sections reorder command contains an invalid section."
);
}
if (
typeof section.sectionId !== "string" ||
!section.sectionId.trim()
) {
throw new Error("sectionId must be a non-empty string.");
}
if (sectionIds.has(section.sectionId)) {
throw new Error(
"Circuit sections reorder command contains duplicate section ids."
);
}
assertCircuitSectionReorderProjectCommand({
schemaVersion: circuitSectionReorderCommandSchemaVersion,
type: "circuit.reorder-section",
payload: {
sectionId: section.sectionId,
assignments: section.assignments,
},
});
sectionIds.add(section.sectionId);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,243 @@
import crypto from "node:crypto";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardInsertCommandType =
"distribution-board.insert" as const;
export const distributionBoardDeleteCommandType =
"distribution-board.delete" as const;
export const distributionBoardStructureCommandSchemaVersion = 1 as const;
export const defaultCircuitSectionDefinitions = [
{
key: "lighting",
displayName: "Lighting",
prefix: "-1F",
sortOrder: 10,
},
{
key: "single_phase",
displayName: "Single-phase circuits",
prefix: "-2F",
sortOrder: 20,
},
{
key: "three_phase",
displayName: "Three-phase circuits",
prefix: "-3F",
sortOrder: 30,
},
{
key: "unassigned",
displayName: "Unassigned",
prefix: "-UF",
sortOrder: 90,
},
] as const;
export interface DistributionBoardStructureSnapshot {
distributionBoard: {
id: string;
projectId: string;
name: string;
};
circuitList: {
id: string;
projectId: string;
distributionBoardId: string;
name: string;
};
sections: Array<{
id: string;
circuitListId: string;
key: string;
displayName: string;
prefix: string;
sortOrder: number;
}>;
}
interface DistributionBoardStructureCommandPayload {
structure: DistributionBoardStructureSnapshot;
}
export interface DistributionBoardInsertProjectCommand
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
type: typeof distributionBoardInsertCommandType;
}
export interface DistributionBoardDeleteProjectCommand
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
type: typeof distributionBoardDeleteCommandType;
}
export type DistributionBoardStructureProjectCommand =
| DistributionBoardInsertProjectCommand
| DistributionBoardDeleteProjectCommand;
export function createDistributionBoardStructureSnapshot(
projectId: string,
name: string
): DistributionBoardStructureSnapshot {
const normalizedName = name.trim();
assertNonEmptyString(projectId, "projectId");
assertNonEmptyString(normalizedName, "name");
const structureId = crypto.randomUUID();
const structure: DistributionBoardStructureSnapshot = {
distributionBoard: {
id: structureId,
projectId,
name: normalizedName,
},
circuitList: {
id: structureId,
projectId,
distributionBoardId: structureId,
name: `${normalizedName} Stromkreisliste`,
},
sections: defaultCircuitSectionDefinitions.map((section) => ({
id: crypto.randomUUID(),
circuitListId: structureId,
...section,
})),
};
assertDistributionBoardStructureSnapshot(structure);
return structure;
}
export function createDistributionBoardInsertProjectCommand(
structure: DistributionBoardStructureSnapshot
): DistributionBoardInsertProjectCommand {
const command: DistributionBoardInsertProjectCommand = {
schemaVersion: distributionBoardStructureCommandSchemaVersion,
type: distributionBoardInsertCommandType,
payload: { structure },
};
assertDistributionBoardInsertProjectCommand(command);
return command;
}
export function createDistributionBoardDeleteProjectCommand(
structure: DistributionBoardStructureSnapshot
): DistributionBoardDeleteProjectCommand {
const command: DistributionBoardDeleteProjectCommand = {
schemaVersion: distributionBoardStructureCommandSchemaVersion,
type: distributionBoardDeleteCommandType,
payload: { structure },
};
assertDistributionBoardDeleteProjectCommand(command);
return command;
}
export function assertDistributionBoardInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardInsertProjectCommand {
assertDistributionBoardStructureProjectCommand(
command,
distributionBoardInsertCommandType
);
}
export function assertDistributionBoardDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardDeleteProjectCommand {
assertDistributionBoardStructureProjectCommand(
command,
distributionBoardDeleteCommandType
);
}
export function assertDistributionBoardStructureSnapshot(
structure: unknown
): asserts structure is DistributionBoardStructureSnapshot {
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
throw new Error("Distribution-board structure is invalid.");
}
const { distributionBoard, circuitList, sections } = structure;
if (
!isPlainObject(distributionBoard) ||
Object.keys(distributionBoard).length !== 3 ||
!isPlainObject(circuitList) ||
Object.keys(circuitList).length !== 4 ||
!Array.isArray(sections) ||
sections.length !== defaultCircuitSectionDefinitions.length
) {
throw new Error("Distribution-board structure is incomplete.");
}
for (const [field, value] of Object.entries(distributionBoard)) {
assertNonEmptyString(value, `distributionBoard.${field}`);
}
for (const [field, value] of Object.entries(circuitList)) {
assertNonEmptyString(value, `circuitList.${field}`);
}
if (
circuitList.projectId !== distributionBoard.projectId ||
circuitList.distributionBoardId !== distributionBoard.id ||
circuitList.name !==
`${distributionBoard.name} Stromkreisliste`
) {
throw new Error(
"Distribution board and circuit list are inconsistent."
);
}
const sectionIds = new Set<string>();
for (let index = 0; index < sections.length; index += 1) {
const section = sections[index];
const expected = defaultCircuitSectionDefinitions[index];
if (
!isPlainObject(section) ||
Object.keys(section).length !== 6
) {
throw new Error("Default circuit section is invalid.");
}
assertNonEmptyString(section.id, "section.id");
if (sectionIds.has(section.id)) {
throw new Error("Default circuit-section ids must be unique.");
}
sectionIds.add(section.id);
if (
section.circuitListId !== circuitList.id ||
section.key !== expected.key ||
section.displayName !== expected.displayName ||
section.prefix !== expected.prefix ||
section.sortOrder !== expected.sortOrder
) {
throw new Error(
"Distribution-board structure has invalid default sections."
);
}
}
}
function assertDistributionBoardStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof distributionBoardInsertCommandType
| typeof distributionBoardDeleteCommandType
) {
if (
command.schemaVersion !==
distributionBoardStructureCommandSchemaVersion ||
command.type !== expectedType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported distribution-board structure command.");
}
assertDistributionBoardStructureSnapshot(command.payload.structure);
}
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,61 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectSettingsUpdateCommandType =
"project.update-settings" as const;
export const projectSettingsUpdateCommandSchemaVersion = 1 as const;
export interface ProjectSettingsValues {
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}
export interface ProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<ProjectSettingsValues> {
schemaVersion: typeof projectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType;
}
export function createProjectSettingsUpdateProjectCommand(
values: ProjectSettingsValues
): ProjectSettingsUpdateProjectCommand {
const command: ProjectSettingsUpdateProjectCommand = {
schemaVersion: projectSettingsUpdateCommandSchemaVersion,
type: projectSettingsUpdateCommandType,
payload: { ...values },
};
assertProjectSettingsUpdateProjectCommand(command);
return command;
}
export function assertProjectSettingsUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectSettingsUpdateProjectCommand {
if (
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion ||
command.type !== projectSettingsUpdateCommandType
) {
throw new Error("Unsupported project settings update command.");
}
if (
!isPlainObject(command.payload) ||
!isPositiveFiniteNumber(command.payload.singlePhaseVoltageV) ||
!isPositiveFiniteNumber(command.payload.threePhaseVoltageV) ||
Object.keys(command.payload).length !== 2
) {
throw new Error(
"Project settings update command contains invalid voltages."
);
}
}
function isPositiveFiniteNumber(value: unknown): value is number {
return (
typeof value === "number" &&
Number.isFinite(value) &&
value > 0
);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,37 @@
export const projectSnapshotKinds = ["named", "automatic"] as const;
export type ProjectSnapshotKind =
(typeof projectSnapshotKinds)[number];
export const automaticProjectSnapshotIntervalRevisions = 25;
export const automaticProjectSnapshotRetentionCount = 12;
export function shouldCaptureAutomaticProjectSnapshot(
currentRevision: number,
latestAutomaticRevision: number | null
) {
assertRevision(currentRevision, "Current");
if (latestAutomaticRevision !== null) {
assertRevision(latestAutomaticRevision, "Latest automatic");
if (latestAutomaticRevision > currentRevision) {
throw new Error(
"Latest automatic snapshot revision exceeds current revision."
);
}
}
if (currentRevision < automaticProjectSnapshotIntervalRevisions) {
return false;
}
return (
latestAutomaticRevision === null ||
currentRevision - latestAutomaticRevision >=
automaticProjectSnapshotIntervalRevisions
);
}
function assertRevision(value: number, label: string) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(
`${label} snapshot revision must be a non-negative integer.`
);
}
}
@@ -0,0 +1,62 @@
import {
parseProjectStateSnapshot,
type ProjectStateSnapshot,
} from "./project-state-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectStateRestoreCommandType = "project.restore-state";
export const projectStateRestoreCommandSchemaVersion = 1 as const;
export interface ProjectStateRestoreCommandPayload {
expectedStateSha256: string;
targetState: ProjectStateSnapshot;
}
export type ProjectStateRestoreCommand = SerializedProjectCommand<
ProjectStateRestoreCommandPayload
> & {
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
type: typeof projectStateRestoreCommandType;
};
export function createProjectStateRestoreCommand(
expectedStateSha256: string,
targetState: ProjectStateSnapshot
): ProjectStateRestoreCommand {
const command: ProjectStateRestoreCommand = {
schemaVersion: projectStateRestoreCommandSchemaVersion,
type: projectStateRestoreCommandType,
payload: {
expectedStateSha256,
targetState: parseProjectStateSnapshot(targetState),
},
};
assertProjectStateRestoreCommand(command);
return command;
}
export function assertProjectStateRestoreCommand(
value: unknown
): asserts value is ProjectStateRestoreCommand {
if (!value || typeof value !== "object") {
throw new Error("Project state restore command must be an object.");
}
const command = value as Partial<ProjectStateRestoreCommand>;
if (
command.type !== projectStateRestoreCommandType ||
command.schemaVersion !== projectStateRestoreCommandSchemaVersion
) {
throw new Error("Unsupported project state restore command.");
}
if (!command.payload || typeof command.payload !== "object") {
throw new Error("Project state restore payload is required.");
}
const payload = command.payload as Partial<ProjectStateRestoreCommandPayload>;
if (
typeof payload.expectedStateSha256 !== "string" ||
!/^[a-f0-9]{64}$/.test(payload.expectedStateSha256)
) {
throw new Error("Expected project state checksum is invalid.");
}
parseProjectStateSnapshot(payload.targetState);
}
@@ -0,0 +1,306 @@
import { z } from "zod";
export const projectStateSnapshotSchemaVersion = 1 as const;
const idSchema = z.string().trim().min(1);
const nullableStringSchema = z.string().nullable();
const finiteNumberSchema = z.number().finite();
const projectSchema = z
.object({
id: idSchema,
name: z.string().trim().min(1),
singlePhaseVoltageV: finiteNumberSchema.positive(),
threePhaseVoltageV: finiteNumberSchema.positive(),
})
.strict();
const distributionBoardSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
})
.strict();
const circuitListSchema = z
.object({
id: idSchema,
projectId: idSchema,
distributionBoardId: idSchema,
name: z.string().trim().min(1),
})
.strict();
const circuitSectionSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
key: z.string().trim().min(1),
displayName: z.string().trim().min(1),
prefix: z.string().trim().min(1),
sortOrder: finiteNumberSchema,
})
.strict();
const circuitDeviceRowSchema = z
.object({
id: idSchema,
circuitId: idSchema,
linkedProjectDeviceId: idSchema.nullable(),
legacyConsumerId: z.string().nullable(),
sortOrder: finiteNumberSchema,
name: z.string().trim().min(1),
displayName: z.string().trim().min(1),
phaseType: nullableStringSchema,
connectionKind: nullableStringSchema,
costGroup: nullableStringSchema,
category: nullableStringSchema,
level: nullableStringSchema,
roomId: idSchema.nullable(),
roomNumberSnapshot: nullableStringSchema,
roomNameSnapshot: nullableStringSchema,
quantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.nonnegative(),
cosPhi: finiteNumberSchema.positive().nullable(),
remark: nullableStringSchema,
overriddenFields: nullableStringSchema,
})
.strict();
const circuitSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
sectionId: idSchema,
equipmentIdentifier: z.string().trim().min(1),
displayName: nullableStringSchema,
sortOrder: finiteNumberSchema,
protectionType: nullableStringSchema,
protectionRatedCurrent: finiteNumberSchema.nonnegative().nullable(),
protectionCharacteristic: nullableStringSchema,
cableType: nullableStringSchema,
cableCrossSection: nullableStringSchema,
cableLength: finiteNumberSchema.nonnegative().nullable(),
rcdAssignment: nullableStringSchema,
terminalDesignation: nullableStringSchema,
voltage: finiteNumberSchema.positive().nullable(),
controlRequirement: nullableStringSchema,
status: nullableStringSchema,
isReserve: z.boolean(),
remark: nullableStringSchema,
deviceRows: z.array(circuitDeviceRowSchema),
})
.strict();
const projectDeviceSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
displayName: z.string().trim().min(1),
phaseType: z.enum(["single_phase", "three_phase"]),
connectionKind: nullableStringSchema,
costGroup: nullableStringSchema,
category: nullableStringSchema,
quantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.min(0).max(1),
cosPhi: finiteNumberSchema.min(0).max(1).nullable(),
remark: nullableStringSchema,
voltageV: finiteNumberSchema.positive().nullable(),
})
.strict();
const floorSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
sortOrder: finiteNumberSchema,
})
.strict();
const roomSchema = z
.object({
id: idSchema,
projectId: idSchema,
floorId: idSchema.nullable(),
roomNumber: z.string().trim().min(1),
roomName: z.string().trim().min(1),
})
.strict();
export const projectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
circuits: z.array(circuitSchema),
projectDevices: z.array(projectDeviceSchema),
floors: z.array(floorSchema),
rooms: z.array(roomSchema),
})
.strict();
export type ProjectStateSnapshot = z.infer<
typeof projectStateSnapshotSchema
>;
export function parseProjectStateSnapshot(
value: unknown
): ProjectStateSnapshot {
const snapshot = projectStateSnapshotSchema.parse(value);
assertProjectStateSnapshotRelations(snapshot);
return snapshot;
}
export function serializeProjectStateSnapshot(
snapshot: ProjectStateSnapshot
): string {
return JSON.stringify(parseProjectStateSnapshot(snapshot));
}
export function deserializeProjectStateSnapshot(
serialized: string
): ProjectStateSnapshot {
return parseProjectStateSnapshot(JSON.parse(serialized) as unknown);
}
function assertProjectStateSnapshotRelations(
snapshot: ProjectStateSnapshot
) {
const projectId = snapshot.project.id;
const boardIds = uniqueIds(
snapshot.distributionBoards,
"distribution board"
);
const circuitListIds = uniqueIds(
snapshot.circuitLists,
"circuit list"
);
const sectionIds = uniqueIds(
snapshot.circuitSections,
"circuit section"
);
const projectDeviceIds = uniqueIds(
snapshot.projectDevices,
"project device"
);
const floorIds = uniqueIds(snapshot.floors, "floor");
const roomIds = uniqueIds(snapshot.rooms, "room");
uniqueIds(snapshot.circuits, "circuit");
const sectionById = new Map(
snapshot.circuitSections.map((entry) => [entry.id, entry])
);
for (const board of snapshot.distributionBoards) {
assertProjectOwnership(board.projectId, projectId, "distribution board");
}
for (const list of snapshot.circuitLists) {
assertProjectOwnership(list.projectId, projectId, "circuit list");
assertReference(
boardIds,
list.distributionBoardId,
"circuit list distribution board"
);
}
for (const section of snapshot.circuitSections) {
assertReference(
circuitListIds,
section.circuitListId,
"circuit section list"
);
}
for (const device of snapshot.projectDevices) {
assertProjectOwnership(device.projectId, projectId, "project device");
}
for (const floor of snapshot.floors) {
assertProjectOwnership(floor.projectId, projectId, "floor");
}
for (const room of snapshot.rooms) {
assertProjectOwnership(room.projectId, projectId, "room");
if (room.floorId !== null) {
assertReference(floorIds, room.floorId, "room floor");
}
}
const deviceRowIds = new Set<string>();
for (const circuit of snapshot.circuits) {
assertReference(
circuitListIds,
circuit.circuitListId,
"circuit list"
);
assertReference(sectionIds, circuit.sectionId, "circuit section");
const section = sectionById.get(circuit.sectionId)!;
if (section.circuitListId !== circuit.circuitListId) {
throw new Error(
"Snapshot circuit section belongs to a different circuit list."
);
}
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
throw new Error(
"Snapshot circuit reserve state must match its device rows."
);
}
for (const row of circuit.deviceRows) {
if (row.circuitId !== circuit.id) {
throw new Error(
"Snapshot device row belongs to a different circuit."
);
}
if (deviceRowIds.has(row.id)) {
throw new Error("Snapshot contains duplicate device row ids.");
}
deviceRowIds.add(row.id);
if (row.linkedProjectDeviceId !== null) {
assertReference(
projectDeviceIds,
row.linkedProjectDeviceId,
"device row project device"
);
}
if (row.roomId !== null) {
assertReference(roomIds, row.roomId, "device row room");
}
}
}
}
function uniqueIds(
entries: ReadonlyArray<{ id: string }>,
label: string
) {
const ids = new Set<string>();
for (const entry of entries) {
if (ids.has(entry.id)) {
throw new Error(`Snapshot contains duplicate ${label} ids.`);
}
ids.add(entry.id);
}
return ids;
}
function assertProjectOwnership(
actualProjectId: string,
expectedProjectId: string,
label: string
) {
if (actualProjectId !== expectedProjectId) {
throw new Error(`Snapshot ${label} belongs to a different project.`);
}
}
function assertReference(
ids: ReadonlySet<string>,
id: string,
label: string
) {
if (!ids.has(id)) {
throw new Error(`Snapshot ${label} reference is invalid.`);
}
}
@@ -1,75 +0,0 @@
export interface CreateCircuitDeviceRowTransactionInput {
circuitId: string;
linkedProjectDeviceId?: string;
sortOrder?: number;
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 CreateCircuitTransactionInput {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
protectionType?: string;
protectionRatedCurrent?: number;
protectionCharacteristic?: string;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
voltage?: number;
controlRequirement?: string;
remark?: string;
rcdAssignment?: string;
terminalDesignation?: string;
status?: string;
}
export interface CreateCircuitWithDeviceRowsTransactionInput {
circuit: CreateCircuitTransactionInput;
deviceRows: Array<Omit<CreateCircuitDeviceRowTransactionInput, "circuitId">>;
}
export interface MoveCircuitDeviceRowsTransactionInput {
rows: Array<{ id: string; expectedCircuitId: string }>;
targetCircuitId?: string;
createTargetCircuit?: {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
};
}
export interface MoveCircuitDeviceRowsTransactionResult {
movedRowIds: string[];
targetCircuitId: string;
createdCircuitId?: string;
}
export interface CircuitDeviceRowTransactionStore {
createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string;
createCircuitWithDeviceRows(
input: CreateCircuitWithDeviceRowsTransactionInput
): { circuitId: string; rowIds: string[] };
deleteFromCircuit(rowId: string, expectedCircuitId: string): void;
moveRows(
input: MoveCircuitDeviceRowsTransactionInput
): MoveCircuitDeviceRowsTransactionResult;
}
@@ -1,4 +1,5 @@
import type { CircuitSectionReorderProjectCommand } from "../models/circuit-section-reorder-project-command.model.js";
import type { CircuitSectionsReorderProjectCommand } from "../models/circuit-sections-reorder-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
@@ -11,12 +12,16 @@ export interface ExecuteCircuitSectionReorderCommandInput {
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitSectionReorderProjectCommand;
command:
| CircuitSectionReorderProjectCommand
| CircuitSectionsReorderProjectCommand;
}
export interface ExecutedCircuitSectionReorderCommand {
revision: AppendedProjectRevision;
inverse: CircuitSectionReorderProjectCommand;
inverse:
| CircuitSectionReorderProjectCommand
| CircuitSectionsReorderProjectCommand;
}
export interface CircuitSectionReorderProjectCommandStore {
@@ -1,8 +0,0 @@
export interface CircuitSectionTransactionStore {
updateEquipmentIdentifiers(
circuitListId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>,
tempNamespace: string
): void;
updateSortOrders(sectionId: string, orderedCircuitIds: string[]): void;
}
@@ -0,0 +1,26 @@
import type { DistributionBoardStructureProjectCommand } from "../models/distribution-board-structure-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteDistributionBoardStructureCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: DistributionBoardStructureProjectCommand;
}
export interface ExecutedDistributionBoardStructureCommand {
revision: AppendedProjectRevision;
inverse: DistributionBoardStructureProjectCommand;
}
export interface DistributionBoardStructureProjectCommandStore {
execute(
input: ExecuteDistributionBoardStructureCommandInput
): ExecutedDistributionBoardStructureCommand;
}
@@ -24,6 +24,9 @@ export interface ProjectCommandExecutor {
executeUser(
input: ExecuteUserProjectCommandInput
): ExecutedProjectCommand;
executeRestore(
input: ExecuteUserProjectCommandInput
): ExecutedProjectCommand;
undo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand;
+29
View File
@@ -1,4 +1,5 @@
import type { SerializedProjectCommand } from "../models/project-command.model.js";
import type { ProjectRevisionSource } from "./project-revision.store.js";
export type ProjectHistoryDirection = "undo" | "redo";
@@ -16,8 +17,36 @@ export interface ProjectHistoryCommand {
command: SerializedProjectCommand;
}
export interface ProjectRevisionSummary {
revisionId: string;
changeSetId: string;
revisionNumber: number;
createdAtIso: string;
actorId: string | null;
source: ProjectRevisionSource;
description: string | null;
commandType: string;
payloadSchemaVersion: number;
}
export interface ProjectRevisionPage {
projectId: string;
currentRevision: number;
revisions: ProjectRevisionSummary[];
nextBeforeRevision: number | null;
}
export interface ListProjectRevisionsInput {
projectId: string;
limit: number;
beforeRevision?: number;
}
export interface ProjectHistoryStore {
getState(projectId: string): ProjectHistoryState | null;
listRevisions(
input: ListProjectRevisionsInput
): ProjectRevisionPage | null;
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
@@ -0,0 +1,26 @@
import type { ProjectSettingsUpdateProjectCommand } from "../models/project-settings-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteProjectSettingsUpdateCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ProjectSettingsUpdateProjectCommand;
}
export interface ExecutedProjectSettingsUpdateCommand {
revision: AppendedProjectRevision;
inverse: ProjectSettingsUpdateProjectCommand;
}
export interface ProjectSettingsProjectCommandStore {
executeUpdate(
input: ExecuteProjectSettingsUpdateCommandInput
): ExecutedProjectSettingsUpdateCommand;
}
@@ -0,0 +1,39 @@
import type { ProjectSnapshotKind } from "../models/project-snapshot-policy.model.js";
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
export interface ProjectSnapshotMetadata {
id: string;
projectId: string;
sourceRevision: number;
schemaVersion: number;
kind: ProjectSnapshotKind;
name: string;
description: string | null;
payloadSha256: string;
createdAtIso: string;
createdByActorId: string | null;
}
export interface CreateNamedProjectSnapshotInput {
projectId: string;
expectedRevision: number;
name: string;
description?: string;
actorId?: string;
}
export interface PreparedProjectSnapshotRestore {
snapshot: ProjectSnapshotMetadata;
command: ProjectStateRestoreCommand;
}
export interface ProjectSnapshotStore {
createNamed(
input: CreateNamedProjectSnapshotInput
): ProjectSnapshotMetadata | null;
listByProject(projectId: string): ProjectSnapshotMetadata[] | null;
prepareRestore(
projectId: string,
snapshotId: string
): PreparedProjectSnapshotRestore | null;
}
@@ -0,0 +1,26 @@
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteProjectStateRestoreCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ProjectStateRestoreCommand;
}
export interface ExecutedProjectStateRestoreCommand {
revision: AppendedProjectRevision;
inverse: ProjectStateRestoreCommand;
}
export interface ProjectStateRestoreCommandStore {
execute(
input: ExecuteProjectStateRestoreCommandInput
): ExecutedProjectStateRestoreCommand;
}
@@ -1,303 +0,0 @@
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 { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import type {
MoveCircuitDeviceRowInput,
MoveCircuitDeviceRowsBulkInput,
ReorderSectionCircuitsInput,
UpdateSectionEquipmentIdentifiersInput,
} from "../../shared/validation/circuit.schemas.js";
import { CircuitNumberingService } from "./circuit-numbering.service.js";
export class CircuitWriteService {
private readonly circuitRepository: CircuitRepository;
private readonly circuitSectionRepository: CircuitSectionRepository;
private readonly deviceRowRepository: CircuitDeviceRowRepository;
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
private readonly numberingService: CircuitNumberingService;
constructor(deps?: {
circuitRepository?: CircuitRepository;
circuitSectionRepository?: CircuitSectionRepository;
deviceRowRepository?: CircuitDeviceRowRepository;
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
numberingService?: CircuitNumberingService;
}) {
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
}
// Ensures writes never connect a section to the wrong circuit list.
private async assertSectionInList(sectionId: string, circuitListId: string) {
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
if (section.circuitListId !== circuitListId) {
throw new Error("Section does not belong to circuit list.");
}
return section;
}
private getDeviceRowTransactionStore() {
if (!this.deviceRowTransactionStore) {
throw new Error("Circuit device-row transactions are not configured.");
}
return this.deviceRowTransactionStore;
}
private getCircuitSectionTransactionStore() {
if (!this.circuitSectionTransactionStore) {
throw new Error("Circuit-section transactions are not configured.");
}
return this.circuitSectionTransactionStore;
}
async deleteCircuit(circuitId: string) {
const current = await this.circuitRepository.findById(circuitId);
if (!current) {
throw new Error("Invalid circuit id.");
}
await this.circuitRepository.delete(circuitId);
}
async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) {
const row = await this.deviceRowRepository.findById(rowId);
if (!row) {
throw new Error("Invalid device row id.");
}
const sourceCircuit = await this.circuitRepository.findById(row.circuitId);
if (!sourceCircuit) {
throw new Error("Invalid circuit id.");
}
let targetCircuit = input.targetCircuitId
? await this.circuitRepository.findById(input.targetCircuitId)
: null;
if (input.targetCircuitId && !targetCircuit) {
throw new Error("Invalid target circuit id.");
}
let createTargetCircuit:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
// Placeholder-target move prepares a new circuit explicitly. Its creation and
// the row assignment are committed together without renumbering other circuits.
if (!targetCircuit) {
if (!input.targetSectionId || !input.createNewCircuit) {
throw new Error("Invalid move target.");
}
const section = await this.assertSectionInList(input.targetSectionId, sourceCircuit.circuitListId);
const nextIdentifier = await this.numberingService.getNextIdentifier(section.id);
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
const nextSortOrder =
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
createTargetCircuit = {
circuitListId: sourceCircuit.circuitListId,
sectionId: section.id,
equipmentIdentifier: nextIdentifier,
displayName: "Neuer Stromkreis",
sortOrder: nextSortOrder,
};
}
if (targetCircuit && targetCircuit.circuitListId !== sourceCircuit.circuitListId) {
throw new Error("Target circuit does not belong to same circuit list.");
}
if (targetCircuit?.id === sourceCircuit.id) {
return this.deviceRowRepository.findById(rowId);
}
this.getDeviceRowTransactionStore().moveRows({
rows: [{ id: row.id, expectedCircuitId: row.circuitId }],
targetCircuitId: targetCircuit?.id,
createTargetCircuit,
});
return this.deviceRowRepository.findById(rowId);
}
async moveDeviceRowsBulk(input: MoveCircuitDeviceRowsBulkInput) {
// Bulk move keeps input order and resolves all source circuits first so undo can
// restore per-source assignment deterministically.
const uniqueRowIds = [...new Set(input.rowIds)];
if (uniqueRowIds.length === 0) {
throw new Error("No device rows provided.");
}
const rows = [];
for (const rowId of uniqueRowIds) {
const row = await this.deviceRowRepository.findById(rowId);
if (!row) {
throw new Error("Invalid device row id.");
}
rows.push(row);
}
const sourceCircuits = new Map<string, Awaited<ReturnType<CircuitRepository["findById"]>>>();
for (const row of rows) {
if (!sourceCircuits.has(row.circuitId)) {
const circuit = await this.circuitRepository.findById(row.circuitId);
if (!circuit) {
throw new Error("Invalid circuit id.");
}
sourceCircuits.set(row.circuitId, circuit);
}
}
const referenceSourceCircuit = sourceCircuits.get(rows[0].circuitId)!;
let targetCircuit = input.targetCircuitId
? await this.circuitRepository.findById(input.targetCircuitId)
: null;
if (input.targetCircuitId && !targetCircuit) {
throw new Error("Invalid target circuit id.");
}
// Bulk placeholder move prepares exactly one new circuit as common target.
// Its actual creation happens together with the row moves in one transaction.
let createTargetCircuit:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
if (!targetCircuit) {
if (!input.targetSectionId || !input.createNewCircuit) {
throw new Error("Invalid move target.");
}
const section = await this.assertSectionInList(input.targetSectionId, referenceSourceCircuit.circuitListId);
const nextIdentifier = await this.numberingService.getNextIdentifier(section.id);
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
const nextSortOrder =
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
createTargetCircuit = {
circuitListId: referenceSourceCircuit.circuitListId,
sectionId: section.id,
equipmentIdentifier: nextIdentifier,
displayName: "Neuer Stromkreis",
sortOrder: nextSortOrder,
};
}
const targetCircuitListId = targetCircuit?.circuitListId ?? createTargetCircuit!.circuitListId;
for (const sourceCircuit of sourceCircuits.values()) {
if (sourceCircuit.circuitListId !== targetCircuitListId) {
throw new Error("All moved rows must belong to same circuit list as target.");
}
}
return this.getDeviceRowTransactionStore().moveRows({
rows: rows.map((row) => ({ id: row.id, expectedCircuitId: row.circuitId })),
targetCircuitId: targetCircuit?.id,
createTargetCircuit,
});
}
async getNextIdentifier(sectionId: string) {
return this.numberingService.getNextIdentifier(sectionId);
}
async renumberSection(sectionId: string) {
// Explicit renumber operation for one section only.
// Never renumbers other sections and never runs implicitly during move/sort operations.
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
const otherCircuits = (await this.circuitRepository.listByCircuitList(section.circuitListId)).filter(
(circuit) => circuit.sectionId !== sectionId
);
const otherIdentifiers = new Set(otherCircuits.map((circuit) => circuit.equipmentIdentifier));
const finalAssignments: Array<{ id: string; equipmentIdentifier: string }> = [];
let index = 1;
for (const circuit of sectionCircuits) {
let candidate = `${section.prefix}${index}`;
while (otherIdentifiers.has(candidate)) {
index += 1;
candidate = `${section.prefix}${index}`;
}
finalAssignments.push({ id: circuit.id, equipmentIdentifier: candidate });
index += 1;
}
// Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps.
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
section.circuitListId,
finalAssignments,
sectionId
);
return this.circuitRepository.listBySection(sectionId);
}
async updateSectionEquipmentIdentifiers(
sectionId: string,
input: UpdateSectionEquipmentIdentifiersInput
) {
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
const sectionIds = new Set(sectionCircuits.map((circuit) => circuit.id));
if (input.identifiers.length !== sectionCircuits.length) {
throw new Error("identifiers must include all circuits in the section.");
}
for (const entry of input.identifiers) {
if (!sectionIds.has(entry.circuitId)) {
throw new Error("Circuit id does not belong to section.");
}
}
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
section.circuitListId,
input.identifiers.map((entry) => ({ id: entry.circuitId, equipmentIdentifier: entry.equipmentIdentifier })),
sectionId
);
return this.circuitRepository.listBySection(sectionId);
}
async reorderCircuitsInSection(sectionId: string, input: ReorderSectionCircuitsInput) {
// Reorder updates sortOrder only. BMKs remain unchanged; users may renumber explicitly later.
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
const sectionIds = new Set(sectionCircuits.map((circuit) => circuit.id));
if (sectionCircuits.length !== input.orderedCircuitIds.length) {
throw new Error("orderedCircuitIds must include all circuits of the section.");
}
for (const circuitId of input.orderedCircuitIds) {
if (!sectionIds.has(circuitId)) {
throw new Error("Circuit id does not belong to section.");
}
}
this.getCircuitSectionTransactionStore().updateSortOrders(
sectionId,
input.orderedCircuitIds
);
return this.circuitRepository.listBySection(sectionId);
}
}
+77 -1
View File
@@ -23,6 +23,10 @@ import {
assertCircuitSectionReorderProjectCommand,
circuitSectionReorderCommandType,
} from "../models/circuit-section-reorder-project-command.model.js";
import {
assertCircuitSectionsReorderProjectCommand,
circuitSectionsReorderCommandType,
} from "../models/circuit-sections-reorder-project-command.model.js";
import {
assertCircuitSectionRenumberProjectCommand,
circuitSectionRenumberCommandType,
@@ -33,10 +37,24 @@ import {
circuitDeleteCommandType,
circuitInsertCommandType,
} from "../models/circuit-structure-project-command.model.js";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
distributionBoardDeleteCommandType,
distributionBoardInsertCommandType,
} from "../models/distribution-board-structure-project-command.model.js";
import {
assertSerializedProjectCommand,
type SerializedProjectCommand,
} from "../models/project-command.model.js";
import {
assertProjectStateRestoreCommand,
projectStateRestoreCommandType,
} from "../models/project-state-restore-command.model.js";
import {
assertProjectSettingsUpdateProjectCommand,
projectSettingsUpdateCommandType,
} from "../models/project-settings-project-command.model.js";
import {
assertProjectDeviceRowSyncProjectCommand,
projectDeviceRowSyncCommandType,
@@ -58,6 +76,7 @@ import type { CircuitProjectCommandStore } from "../ports/circuit-project-comman
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 { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js";
import type {
ExecuteProjectHistoryCommandInput,
ExecutedProjectCommand,
@@ -72,6 +91,8 @@ import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-p
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";
import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js";
import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js";
interface DispatchProjectCommandInput {
projectId: string;
@@ -80,7 +101,7 @@ interface DispatchProjectCommandInput {
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: SerializedProjectCommand;
command: SerializedProjectCommand<unknown>;
}
export class ProjectCommandService implements ProjectCommandExecutor {
@@ -90,11 +111,14 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly distributionBoardStructureStore: DistributionBoardStructureProjectCommandStore,
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -103,6 +127,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
): ExecutedProjectCommand {
assertExpectedRevision(input.expectedRevision);
assertSerializedProjectCommand(input.command);
if (input.command.type === projectStateRestoreCommandType) {
throw new Error(
"Project state restore commands require a stored server snapshot."
);
}
return this.dispatch({
...input,
source: "user",
@@ -110,6 +139,18 @@ export class ProjectCommandService implements ProjectCommandExecutor {
});
}
executeRestore(
input: ExecuteUserProjectCommandInput
): ExecutedProjectCommand {
assertExpectedRevision(input.expectedRevision);
assertProjectStateRestoreCommand(input.command);
return this.dispatch({
...input,
source: "restore",
command: input.command,
});
}
undo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand {
@@ -218,6 +259,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case distributionBoardInsertCommandType: {
assertDistributionBoardInsertProjectCommand(input.command);
return this.distributionBoardStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case distributionBoardDeleteCommandType: {
assertDistributionBoardDeleteProjectCommand(input.command);
return this.distributionBoardStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitSectionReorderCommandType: {
assertCircuitSectionReorderProjectCommand(input.command);
return this.circuitSectionReorderStore.execute({
@@ -225,6 +280,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case circuitSectionsReorderCommandType: {
assertCircuitSectionsReorderProjectCommand(input.command);
return this.circuitSectionReorderStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitSectionRenumberCommandType: {
assertCircuitSectionRenumberProjectCommand(input.command);
return this.circuitSectionRenumberStore.execute({
@@ -260,6 +322,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case projectSettingsUpdateCommandType: {
assertProjectSettingsUpdateProjectCommand(input.command);
return this.projectSettingsStore.executeUpdate({
...input,
command: input.command,
}).revision;
}
case projectStateRestoreCommandType: {
assertProjectStateRestoreCommand(input.command);
return this.projectStateRestoreStore.execute({
...input,
command: input.command,
}).revision;
}
default:
throw new Error(
`Unsupported project command type: ${input.command.type}.`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,504 @@
"use client";
import React, {
FormEvent,
useCallback,
useEffect,
useState,
} from "react";
import type {
ProjectHistoryStateDto,
ProjectRevisionPageDto,
ProjectRevisionSummaryDto,
ProjectSnapshotMetadataDto,
} from "../types";
import {
createNamedProjectSnapshot,
getProjectHistory,
listProjectRevisions,
listProjectSnapshots,
redoProjectCommand,
restoreProjectSnapshot,
undoProjectCommand,
} from "../utils/api";
import {
getProjectRevisionDescription,
getProjectRevisionSourceLabel,
getProjectSnapshotKindLabel,
mergeProjectRevisionPages,
} from "../utils/project-version-history";
interface ProjectVersionHistoryProps {
projectId: string;
currentRevision: number;
onProjectStateChange: () => void;
}
const revisionPageSize = 10;
export function ProjectVersionHistory({
projectId,
currentRevision,
onProjectStateChange,
}: ProjectVersionHistoryProps) {
const [history, setHistory] =
useState<ProjectHistoryStateDto | null>(null);
const [snapshots, setSnapshots] = useState<ProjectSnapshotMetadataDto[]>(
[]
);
const [revisions, setRevisions] = useState<ProjectRevisionSummaryDto[]>(
[]
);
const [nextBeforeRevision, setNextBeforeRevision] = useState<
number | null
>(null);
const [snapshotName, setSnapshotName] = useState("");
const [snapshotDescription, setSnapshotDescription] = useState("");
const [restoreCandidate, setRestoreCandidate] =
useState<ProjectSnapshotMetadataDto | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isHistoryLoading, setIsHistoryLoading] = useState(true);
const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadHistory = useCallback(async () => {
setIsHistoryLoading(true);
try {
setHistory(await getProjectHistory(projectId));
} catch (loadError) {
setError(
getVersionHistoryError(
loadError,
"Rückgängig-Status konnte nicht geladen werden."
)
);
} finally {
setIsHistoryLoading(false);
}
}, [projectId]);
const loadOverview = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const [loadedSnapshots, revisionPage] = await Promise.all([
listProjectSnapshots(projectId),
listProjectRevisions(projectId, { limit: revisionPageSize }),
]);
setSnapshots(loadedSnapshots);
setRevisions(revisionPage.revisions);
setNextBeforeRevision(revisionPage.nextBeforeRevision);
} catch (loadError) {
setError(
getVersionHistoryError(
loadError,
"Versionshistorie konnte nicht geladen werden."
)
);
} finally {
setIsLoading(false);
}
}, [projectId]);
useEffect(() => {
void loadHistory();
}, [currentRevision, loadHistory]);
useEffect(() => {
if (isOpen) {
void loadOverview();
}
}, [loadOverview, currentRevision, isOpen]);
async function handleCreateSnapshot(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const name = snapshotName.trim();
if (!name) {
return;
}
setIsBusy(true);
setError(null);
try {
const created = await createNamedProjectSnapshot(
projectId,
currentRevision,
name,
snapshotDescription.trim() || undefined
);
setSnapshots((current) => [created, ...current]);
setSnapshotName("");
setSnapshotDescription("");
} catch (createError) {
setError(
getVersionHistoryError(
createError,
"Projektstand konnte nicht gespeichert werden."
)
);
} finally {
setIsBusy(false);
}
}
async function handleLoadOlderRevisions() {
if (nextBeforeRevision === null) {
return;
}
setIsBusy(true);
setError(null);
try {
const page: ProjectRevisionPageDto = await listProjectRevisions(
projectId,
{
limit: revisionPageSize,
beforeRevision: nextBeforeRevision,
}
);
setRevisions((current) =>
mergeProjectRevisionPages(current, page.revisions)
);
setNextBeforeRevision(page.nextBeforeRevision);
} catch (loadError) {
setError(
getVersionHistoryError(
loadError,
"Ältere Revisionen konnten nicht geladen werden."
)
);
} finally {
setIsBusy(false);
}
}
async function handleRestoreSnapshot() {
if (!restoreCandidate) {
return;
}
setIsBusy(true);
setError(null);
try {
await restoreProjectSnapshot(
projectId,
restoreCandidate.id,
currentRevision
);
setRestoreCandidate(null);
setIsBusy(false);
onProjectStateChange();
} catch (restoreError) {
setError(
getVersionHistoryError(
restoreError,
"Projektstand konnte nicht wiederhergestellt werden."
)
);
setRestoreCandidate(null);
setIsBusy(false);
}
}
async function handleHistoryCommand(direction: "undo" | "redo") {
setIsBusy(true);
setError(null);
try {
const result =
direction === "undo"
? await undoProjectCommand(projectId, currentRevision)
: await redoProjectCommand(projectId, currentRevision);
setHistory(result.history);
onProjectStateChange();
} catch (historyError) {
setError(
getVersionHistoryError(
historyError,
direction === "undo"
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
: "Die letzte Änderung konnte nicht wiederholt werden."
)
);
} finally {
setIsBusy(false);
}
}
const historyIsCurrent =
history?.currentRevision === currentRevision;
return (
<section className="card shadow-sm">
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<span>Versionen und Sicherungspunkte</span>
<div className="d-flex align-items-center gap-2">
<span className="badge text-bg-secondary">
Aktuelle Revision {currentRevision}
</span>
<button
className="btn btn-sm btn-outline-secondary"
disabled={
isBusy ||
isHistoryLoading ||
!historyIsCurrent ||
!history?.undoDepth
}
onClick={() => void handleHistoryCommand("undo")}
title={
history?.undoDepth
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
: "Keine Änderung zum Rückgängigmachen"
}
type="button"
>
Rückgängig
</button>
<button
className="btn btn-sm btn-outline-secondary"
disabled={
isBusy ||
isHistoryLoading ||
!historyIsCurrent ||
!history?.redoDepth
}
onClick={() => void handleHistoryCommand("redo")}
title={
history?.redoDepth
? `${history.redoDepth} Änderung(en) können wiederholt werden`
: "Keine Änderung zum Wiederholen"
}
type="button"
>
Wiederholen
</button>
<button
aria-expanded={isOpen}
className="btn btn-sm btn-outline-secondary"
onClick={() => setIsOpen((current) => !current)}
type="button"
>
{isOpen ? "Ausblenden" : "Anzeigen"}
</button>
</div>
</div>
{error ? (
<div className="alert alert-warning m-3 mb-0" role="alert">
{error}
</div>
) : null}
{isOpen ? (
<>
<div className="card-body border-bottom">
<h2 className="h6">Neuen Sicherungspunkt anlegen</h2>
<p className="small text-secondary">
Ein Sicherungspunkt speichert den vollständigen Projektstand, ohne
die Revision oder Rückgängig-Historie zu verändern.
</p>
<form className="row g-2" onSubmit={handleCreateSnapshot}>
<div className="col-12 col-lg-4">
<label className="form-label" htmlFor="snapshot-name">
Name
</label>
<input
className="form-control"
id="snapshot-name"
maxLength={100}
placeholder="z. B. Vor Planfreigabe"
value={snapshotName}
onChange={(event) => setSnapshotName(event.target.value)}
/>
</div>
<div className="col-12 col-lg-6">
<label className="form-label" htmlFor="snapshot-description">
Beschreibung (optional)
</label>
<input
className="form-control"
id="snapshot-description"
maxLength={500}
placeholder="Was kennzeichnet diesen Stand?"
value={snapshotDescription}
onChange={(event) =>
setSnapshotDescription(event.target.value)
}
/>
</div>
<div className="col-12 col-lg-2 d-flex align-items-end">
<button
className="btn btn-primary w-100"
disabled={isBusy || !snapshotName.trim()}
type="submit"
>
Stand speichern
</button>
</div>
</form>
</div>
<div className="card-body border-bottom">
<h2 className="h6">Gespeicherte Sicherungspunkte</h2>
{isLoading ? (
<p className="text-secondary mb-0">Sicherungspunkte werden geladen </p>
) : snapshots.length ? (
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Name</th>
<th>Projektstand</th>
<th>Erstellt</th>
<th className="text-end">Aktion</th>
</tr>
</thead>
<tbody>
{snapshots.map((snapshot) => (
<tr key={snapshot.id}>
<td>
<strong>{snapshot.name}</strong>
<span
className={`badge ms-2 ${
snapshot.kind === "automatic"
? "text-bg-info"
: "text-bg-secondary"
}`}
>
{getProjectSnapshotKindLabel(snapshot.kind)}
</span>
{snapshot.description ? (
<div className="small text-secondary">
{snapshot.description}
</div>
) : null}
</td>
<td>Revision {snapshot.sourceRevision}</td>
<td>{formatDateTime(snapshot.createdAtIso)}</td>
<td className="text-end">
<button
className="btn btn-sm btn-outline-danger"
disabled={isBusy}
onClick={() => setRestoreCandidate(snapshot)}
type="button"
>
Wiederherstellen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-secondary mb-0">
Noch keine Sicherungspunkte vorhanden.
</p>
)}
{restoreCandidate ? (
<div className="alert alert-danger mt-3 mb-0" role="alert">
<h3 className="h6">
{restoreCandidate.name} wirklich wiederherstellen?
</h3>
<p className="mb-2">
Verteiler, Stromkreise, Projektgeräte, Räume und
Projekteinstellungen werden auf Revision{" "}
{restoreCandidate.sourceRevision} zurückgesetzt. Der aktuelle
Stand bleibt als neue rückgängig machbare Revision erhalten.
</p>
<div className="d-flex flex-wrap gap-2">
<button
className="btn btn-danger"
disabled={isBusy}
onClick={() => void handleRestoreSnapshot()}
type="button"
>
Jetzt wiederherstellen
</button>
<button
className="btn btn-outline-secondary"
disabled={isBusy}
onClick={() => setRestoreCandidate(null)}
type="button"
>
Abbrechen
</button>
</div>
</div>
) : null}
</div>
<div className="card-body">
<h2 className="h6">Letzte Änderungen</h2>
{isLoading ? (
<p className="text-secondary mb-0">Historie wird geladen </p>
) : revisions.length ? (
<>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Revision</th>
<th>Zeitpunkt</th>
<th>Art</th>
<th>Änderung</th>
</tr>
</thead>
<tbody>
{revisions.map((revision) => (
<tr key={revision.revisionId}>
<td>{revision.revisionNumber}</td>
<td>{formatDateTime(revision.createdAtIso)}</td>
<td>
{getProjectRevisionSourceLabel(revision.source)}
</td>
<td>{getProjectRevisionDescription(revision)}</td>
</tr>
))}
</tbody>
</table>
</div>
{nextBeforeRevision !== null ? (
<button
className="btn btn-sm btn-outline-secondary mt-3"
disabled={isBusy}
onClick={() => void handleLoadOlderRevisions()}
type="button"
>
Ältere Änderungen laden
</button>
) : null}
</>
) : (
<p className="text-secondary mb-0">
Für dieses Projekt wurden noch keine versionierten Änderungen
gespeichert.
</p>
)}
</div>
</>
) : null}
</section>
);
}
function formatDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function getVersionHistoryError(error: unknown, fallback: string) {
const details = error instanceof Error ? error.message : "";
if (details.includes("PROJECT_SNAPSHOT_NAME_CONFLICT")) {
return "Ein Sicherungspunkt mit diesem Namen ist bereits vorhanden.";
}
if (
details.includes("PROJECT_REVISION_CONFLICT") ||
details.includes("PROJECT_STATE_CONFLICT")
) {
return "Das Projekt wurde inzwischen geändert. Bitte lade die Seite neu und versuche es erneut.";
}
return fallback;
}
+54
View File
@@ -15,6 +15,55 @@ export interface ProjectHistoryStateDto {
redoChangeSetId: string | null;
}
export type ProjectRevisionSourceDto =
| "user"
| "undo"
| "redo"
| "restore"
| "migration";
export interface ProjectRevisionSummaryDto {
revisionId: string;
changeSetId: string;
revisionNumber: number;
createdAtIso: string;
actorId: string | null;
source: ProjectRevisionSourceDto;
description: string | null;
commandType: string;
payloadSchemaVersion: number;
}
export interface ProjectRevisionPageDto {
projectId: string;
currentRevision: number;
revisions: ProjectRevisionSummaryDto[];
nextBeforeRevision: number | null;
}
export interface ProjectSnapshotMetadataDto {
id: string;
projectId: string;
sourceRevision: number;
schemaVersion: number;
kind: "named" | "automatic";
name: string;
description: string | null;
payloadSha256: string;
createdAtIso: string;
createdByActorId: string | null;
}
export interface ProjectSnapshotRestoreResultDto
extends ProjectCommandResultDto {
snapshot: ProjectSnapshotMetadataDto;
}
export interface ProjectSettingsCommandResultDto
extends ProjectCommandResultDto {
project: ProjectDto;
}
export interface ProjectCommandDto {
schemaVersion: number;
type: string;
@@ -38,6 +87,11 @@ export interface DistributionBoardDto {
name: string;
}
export interface DistributionBoardCommandResultDto
extends ProjectCommandResultDto {
distributionBoard: DistributionBoardDto;
}
export interface CircuitListDto {
id: string;
projectId: string;
+181 -54
View File
@@ -5,6 +5,7 @@ import type {
CreateRoomInput,
CreateGlobalDeviceInput,
DistributionBoardDto,
DistributionBoardCommandResultDto,
FloorDto,
GlobalDeviceDto,
ProjectCommandDto,
@@ -12,6 +13,10 @@ import type {
ProjectDeviceCommandResultDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
ProjectRevisionPageDto,
ProjectSnapshotMetadataDto,
ProjectSnapshotRestoreResultDto,
ProjectSettingsCommandResultDto,
ProjectDeviceSyncCommandResultDto,
ProjectDeviceSyncPreviewDto,
ProjectDto,
@@ -33,6 +38,18 @@ import type {
import type {
CircuitSnapshot,
} from "../../domain/models/circuit-structure-project-command.model";
import type {
CircuitDeviceRowMoveAssignment,
} from "../../domain/models/circuit-device-row-move-project-command.model";
import type {
CircuitSectionReorderAssignment,
} from "../../domain/models/circuit-section-reorder-project-command.model";
import type {
CircuitSectionsReorderSection,
} from "../../domain/models/circuit-sections-reorder-project-command.model";
import type {
CircuitSectionRenumberAssignment,
} from "../../domain/models/circuit-section-renumber-project-command.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -70,6 +87,62 @@ export function getProjectHistory(projectId: string) {
);
}
export function listProjectRevisions(
projectId: string,
input: { limit?: number; beforeRevision?: number } = {}
) {
const query = new URLSearchParams();
if (input.limit !== undefined) {
query.set("limit", String(input.limit));
}
if (input.beforeRevision !== undefined) {
query.set("beforeRevision", String(input.beforeRevision));
}
const suffix = query.size ? `?${query.toString()}` : "";
return request<ProjectRevisionPageDto>(
`/api/projects/${projectId}/history/revisions${suffix}`
);
}
export function listProjectSnapshots(projectId: string) {
return request<ProjectSnapshotMetadataDto[]>(
`/api/projects/${projectId}/snapshots`
);
}
export function createNamedProjectSnapshot(
projectId: string,
expectedRevision: number,
name: string,
description?: string
) {
return request<ProjectSnapshotMetadataDto>(
`/api/projects/${projectId}/snapshots`,
{
method: "POST",
body: JSON.stringify({
expectedRevision,
name,
...(description ? { description } : {}),
}),
}
);
}
export function restoreProjectSnapshot(
projectId: string,
snapshotId: string,
expectedRevision: number
) {
return request<ProjectSnapshotRestoreResultDto>(
`/api/projects/${projectId}/snapshots/${snapshotId}/restore`,
{
method: "POST",
body: JSON.stringify({ expectedRevision }),
}
);
}
export function executeProjectCommand(
projectId: string,
expectedRevision: number,
@@ -134,23 +207,34 @@ export function createProject(name: string) {
export function updateProjectSettings(
projectId: string,
expectedRevision: number,
input: { singlePhaseVoltageV: number; threePhaseVoltageV: number }
) {
return request<ProjectDto>(`/api/projects/${projectId}`, {
return request<ProjectSettingsCommandResultDto>(
`/api/projects/${projectId}`,
{
method: "PUT",
body: JSON.stringify(input),
});
body: JSON.stringify({ expectedRevision, ...input }),
}
);
}
export function listDistributionBoards(projectId: string) {
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
}
export function createDistributionBoard(projectId: string, name: string) {
return request<DistributionBoardDto>(`/api/projects/${projectId}/distribution-boards`, {
method: "POST",
body: JSON.stringify({ name }),
});
export function createDistributionBoard(
projectId: string,
name: string,
expectedRevision: number
) {
return request<DistributionBoardCommandResultDto>(
`/api/projects/${projectId}/distribution-boards`,
{
method: "POST",
body: JSON.stringify({ name, expectedRevision }),
}
);
}
export function listCircuitLists(projectId: string) {
@@ -212,12 +296,6 @@ export function deleteCircuitCommand(
);
}
export function deleteCircuitById(circuitId: string) {
return request<void>(`/api/circuits/${circuitId}`, {
method: "DELETE",
});
}
export function getNextCircuitIdentifier(sectionId: string) {
return request<{ sectionId: string; nextIdentifier: string }>(
`/api/circuit-sections/${sectionId}/next-identifier`
@@ -275,52 +353,101 @@ export function deleteCircuitDeviceRowCommand(
);
}
export function moveCircuitDeviceRowById(
rowId: string,
input: { targetCircuitId?: string; targetSectionId?: string; createNewCircuit?: boolean }
export function moveCircuitDeviceRowsCommand(
projectId: string,
expectedRevision: number,
moves: CircuitDeviceRowMoveAssignment[],
description: string
) {
return request(`/api/circuit-device-rows/${rowId}/move`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function moveCircuitDeviceRowsBulk(input: {
rowIds: string[];
targetCircuitId?: string;
targetSectionId?: string;
createNewCircuit?: boolean;
}) {
return request<{ movedRowIds: string[]; targetCircuitId: string; createdCircuitId?: string }>(
"/api/circuit-device-rows/move-bulk",
return executeProjectCommand(
projectId,
expectedRevision,
{
method: "PATCH",
body: JSON.stringify(input),
}
schemaVersion: 1,
type: "circuit-device-row.move",
payload: { moves },
},
description
);
}
export function renumberCircuitSection(sectionId: string) {
return request(`/api/circuit-sections/${sectionId}/renumber`, {
method: "POST",
});
}
export function reorderSectionCircuits(sectionId: string, orderedCircuitIds: string[]) {
return request(`/api/circuit-sections/${sectionId}/circuits/reorder`, {
method: "PATCH",
body: JSON.stringify({ orderedCircuitIds }),
});
}
export function updateSectionEquipmentIdentifiers(
sectionId: string,
identifiers: Array<{ circuitId: string; equipmentIdentifier: string }>
export function moveCircuitDeviceRowsToNewCircuitCommand(
projectId: string,
expectedRevision: number,
targetCircuit: CircuitSnapshot,
moves: CircuitDeviceRowMoveAssignment[],
description: string
) {
return request(`/api/circuit-sections/${sectionId}/equipment-identifiers`, {
method: "PATCH",
body: JSON.stringify({ identifiers }),
});
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-device-row.move-with-new-circuit",
payload: {
targetCircuitAction: "create",
targetCircuit,
moves,
},
},
description
);
}
export function reorderCircuitSectionCommand(
projectId: string,
expectedRevision: number,
sectionId: string,
assignments: CircuitSectionReorderAssignment[],
description: string
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit.reorder-section",
payload: { sectionId, assignments },
},
description
);
}
export function reorderCircuitSectionsCommand(
projectId: string,
expectedRevision: number,
sections: CircuitSectionsReorderSection[],
description: string
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit.reorder-sections",
payload: { sections },
},
description
);
}
export function renumberCircuitSectionCommand(
projectId: string,
expectedRevision: number,
sectionId: string,
assignments: CircuitSectionRenumberAssignment[],
description: string
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit.renumber-section",
payload: { sectionId, assignments },
},
description
);
}
export function listFloors(projectId: string) {
@@ -0,0 +1,66 @@
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
} from "../types.js";
import type {
CircuitDeviceRowMoveAssignment,
} from "../../domain/models/circuit-device-row-move-project-command.model.js";
export function buildCircuitDeviceRowMoveAssignments(input: {
circuits: CircuitTreeCircuitDto[];
rowIds: string[];
targetCircuitId: string;
targetDeviceRows: CircuitTreeDeviceRowDto[];
}): CircuitDeviceRowMoveAssignment[] {
const { circuits, rowIds, targetCircuitId, targetDeviceRows } =
input;
if (new Set(rowIds).size !== rowIds.length) {
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
}
const sourceByRowId = new Map<
string,
{ circuitId: string; sortOrder: number }
>();
for (const circuit of circuits) {
for (const row of circuit.deviceRows) {
sourceByRowId.set(row.id, {
circuitId: circuit.id,
sortOrder: row.sortOrder,
});
}
}
const movedRowIds = new Set(rowIds);
const retainedTargetRows = targetDeviceRows.filter(
(row) => !movedRowIds.has(row.id)
);
const lastTargetSortOrder = retainedTargetRows.reduce(
(highest, row) => Math.max(highest, row.sortOrder),
0
);
return rowIds.flatMap((rowId, index) => {
const source = sourceByRowId.get(rowId);
if (!source) {
throw new Error("Eine ausgewählte Gerätezeile wurde nicht gefunden.");
}
const targetSortOrder =
lastTargetSortOrder + (index + 1) * 10;
if (
source.circuitId === targetCircuitId &&
source.sortOrder === targetSortOrder
) {
return [];
}
return [
{
rowId,
expectedCircuitId: source.circuitId,
expectedSortOrder: source.sortOrder,
targetCircuitId,
targetSortOrder,
},
];
});
}
@@ -0,0 +1,27 @@
import type {
CircuitTreeResponseDto,
ProjectHistoryStateDto,
} from "../types";
export interface CircuitEditorSnapshot {
tree: CircuitTreeResponseDto;
history: ProjectHistoryStateDto;
}
export async function loadCircuitEditorSnapshot(
readTree: () => Promise<CircuitTreeResponseDto>,
readHistory: () => Promise<ProjectHistoryStateDto>,
maxAttempts = 3
): Promise<CircuitEditorSnapshot> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const tree = await readTree();
const history = await readHistory();
if (tree.currentRevision === history.currentRevision) {
return { tree, history };
}
}
throw new Error(
"Der Projektstand hat sich während des Ladens geändert. Bitte erneut versuchen."
);
}
@@ -0,0 +1,54 @@
import type {
CircuitSectionRenumberAssignment,
} from "../../domain/models/circuit-section-renumber-project-command.model.js";
import type { CircuitTreeSectionDto } from "../types.js";
export function buildCircuitSectionRenumberAssignments(
sections: CircuitTreeSectionDto[],
sectionId: string
): CircuitSectionRenumberAssignment[] {
const targetSection = sections.find(
(section) => section.id === sectionId
);
if (!targetSection) {
throw new Error("Der Bereich wurde nicht gefunden.");
}
const identifiersOutsideSection = new Set(
sections
.filter((section) => section.id !== sectionId)
.flatMap((section) =>
section.circuits.map(
(circuit) => circuit.equipmentIdentifier
)
)
);
const assignments: CircuitSectionRenumberAssignment[] = [];
let suffix = 1;
for (const circuit of targetSection.circuits) {
let targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
while (
identifiersOutsideSection.has(targetEquipmentIdentifier)
) {
suffix += 1;
targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
}
assignments.push({
circuitId: circuit.id,
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
targetEquipmentIdentifier,
});
suffix += 1;
}
if (
assignments.every(
(assignment) =>
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
)
) {
return [];
}
return assignments;
}
@@ -0,0 +1,44 @@
import type {
CircuitSectionReorderAssignment,
} from "../../domain/models/circuit-section-reorder-project-command.model.js";
import type { CircuitTreeCircuitDto } from "../types.js";
export function buildCircuitSectionReorderAssignments(
circuits: CircuitTreeCircuitDto[],
orderedCircuitIds: string[]
): CircuitSectionReorderAssignment[] {
if (
circuits.length !== orderedCircuitIds.length ||
new Set(orderedCircuitIds).size !== orderedCircuitIds.length
) {
throw new Error(
"Die Reihenfolge muss jeden Stromkreis des Bereichs genau einmal enthalten."
);
}
const circuitsById = new Map(
circuits.map((circuit) => [circuit.id, circuit])
);
const assignments = orderedCircuitIds.map((circuitId, index) => {
const circuit = circuitsById.get(circuitId);
if (!circuit) {
throw new Error(
"Die Reihenfolge enthält einen ungültigen Stromkreis."
);
}
return {
circuitId,
expectedSortOrder: circuit.sortOrder,
targetSortOrder: (index + 1) * 10,
};
});
if (
assignments.every(
(assignment) =>
assignment.expectedSortOrder === assignment.targetSortOrder
)
) {
return [];
}
return assignments;
}
@@ -0,0 +1,73 @@
import type {
ProjectRevisionSourceDto,
ProjectRevisionSummaryDto,
ProjectSnapshotMetadataDto,
} from "../types";
const sourceLabels: Record<ProjectRevisionSourceDto, string> = {
user: "Bearbeitung",
undo: "Rückgängig",
redo: "Wiederholt",
restore: "Wiederherstellung",
migration: "Migration",
};
const commandTypeLabels: Record<string, string> = {
"circuit.update": "Stromkreis bearbeitet",
"circuit.insert": "Stromkreis angelegt",
"circuit.delete": "Stromkreis gelöscht",
"circuit-device-row.update": "Gerätezeile bearbeitet",
"circuit-device-row.insert": "Gerätezeile angelegt",
"circuit-device-row.delete": "Gerätezeile gelöscht",
"circuit-device-row.move": "Gerätezeile verschoben",
"circuit-device-row.move-with-new-circuit":
"Gerätezeile in neuen Stromkreis verschoben",
"circuit.reorder-section": "Stromkreise sortiert",
"circuit.reorder-sections": "Stromkreise bereichsübergreifend sortiert",
"circuit.renumber-section": "Bereich neu nummeriert",
"project-device.update": "Projektgerät bearbeitet",
"project-device.insert": "Projektgerät angelegt",
"project-device.delete": "Projektgerät gelöscht",
"project-device.sync-rows": "Projektgerät-Verknüpfungen geändert",
"project.update-settings": "Projekteinstellungen bearbeitet",
"distribution-board.insert": "Verteilung angelegt",
"distribution-board.delete": "Verteilung entfernt",
"project.restore-state": "Projektstand wiederhergestellt",
};
export function getProjectRevisionSourceLabel(
source: ProjectRevisionSourceDto
) {
return sourceLabels[source];
}
export function getProjectRevisionDescription(
revision: ProjectRevisionSummaryDto
) {
return (
revision.description?.trim() ||
commandTypeLabels[revision.commandType] ||
revision.commandType
);
}
export function getProjectSnapshotKindLabel(
kind: ProjectSnapshotMetadataDto["kind"]
) {
return kind === "automatic" ? "Automatisch" : "Benannt";
}
export function mergeProjectRevisionPages(
current: ProjectRevisionSummaryDto[],
next: ProjectRevisionSummaryDto[]
) {
const byId = new Map(
[...current, ...next].map((revision) => [
revision.revisionId,
revision,
])
);
return [...byId.values()].sort(
(left, right) => right.revisionNumber - left.revisionNumber
);
}
@@ -0,0 +1,3 @@
import { CircuitNumberingService } from "../../domain/services/circuit-numbering.service.js";
export const circuitNumberingService = new CircuitNumberingService();
@@ -1,9 +0,0 @@
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
import { db } from "../../db/client.js";
import { CircuitDeviceRowTransactionRepository } from "../../db/repositories/circuit-device-row-transaction.repository.js";
import { CircuitSectionTransactionRepository } from "../../db/repositories/circuit-section-transaction.repository.js";
export const circuitWriteService = new CircuitWriteService({
deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db),
circuitSectionTransactionStore: new CircuitSectionTransactionRepository(db),
});
@@ -6,10 +6,13 @@ import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-p
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 { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-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 { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js";
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
@@ -21,6 +24,8 @@ export const circuitDeviceRowMoveProjectCommandStore =
new CircuitDeviceRowMoveProjectCommandRepository(db);
export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const distributionBoardStructureProjectCommandStore =
new DistributionBoardStructureProjectCommandRepository(db);
export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(db);
export const circuitSectionRenumberProjectCommandStore =
@@ -31,6 +36,10 @@ export const projectDeviceProjectCommandStore =
new ProjectDeviceProjectCommandRepository(db);
export const projectDeviceRowSyncProjectCommandStore =
new ProjectDeviceRowSyncProjectCommandRepository(db);
export const projectSettingsProjectCommandStore =
new ProjectSettingsProjectCommandRepository(db);
export const projectStateRestoreCommandStore =
new ProjectStateRestoreCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
@@ -38,10 +47,13 @@ export const projectCommandService = new ProjectCommandService(
circuitDeviceRowStructureProjectCommandStore,
circuitDeviceRowMoveProjectCommandStore,
circuitStructureProjectCommandStore,
distributionBoardStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore,
circuitSectionRenumberProjectCommandStore,
projectDeviceStructureProjectCommandStore,
projectDeviceProjectCommandStore,
projectDeviceRowSyncProjectCommandStore,
projectSettingsProjectCommandStore,
projectStateRestoreCommandStore,
projectHistoryStore
);
@@ -0,0 +1,5 @@
import { db } from "../../db/client.js";
import { ProjectSnapshotRepository } from "../../db/repositories/project-snapshot.repository.js";
export const projectSnapshotStore =
new ProjectSnapshotRepository(db);
@@ -1,39 +0,0 @@
import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js";
import {
moveCircuitDeviceRowsBulkSchema,
moveCircuitDeviceRowSchema,
} from "../../shared/validation/circuit.schemas.js";
export async function moveCircuitDeviceRow(req: Request, res: Response) {
const { rowId } = req.params;
if (typeof rowId !== "string") {
return res.status(400).json({ error: "Invalid rowId" });
}
const parsed = moveCircuitDeviceRowSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const moved = await circuitWriteService.moveDeviceRow(rowId, parsed.data);
return res.json(moved);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device row." });
}
}
export async function moveCircuitDeviceRowsBulk(req: Request, res: Response) {
const parsed = moveCircuitDeviceRowsBulkSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const moved = await circuitWriteService.moveDeviceRowsBulk(parsed.data);
return res.json(moved);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device rows." });
}
}
@@ -1,58 +0,0 @@
import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js";
import {
reorderSectionCircuitsSchema,
updateSectionEquipmentIdentifiersSchema,
} from "../../shared/validation/circuit.schemas.js";
export async function renumberCircuitSection(req: Request, res: Response) {
const { sectionId } = req.params;
if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid sectionId" });
}
try {
const updatedCircuits = await circuitWriteService.renumberSection(sectionId);
return res.json({ sectionId, circuits: updatedCircuits });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to renumber section." });
}
}
export async function reorderSectionCircuits(req: Request, res: Response) {
const { sectionId } = req.params;
if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid sectionId" });
}
const parsed = reorderSectionCircuitsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const updatedCircuits = await circuitWriteService.reorderCircuitsInSection(sectionId, parsed.data);
return res.json({ sectionId, circuits: updatedCircuits });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to reorder circuits." });
}
}
export async function updateSectionEquipmentIdentifiers(req: Request, res: Response) {
const { sectionId } = req.params;
if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid sectionId" });
}
const parsed = updateSectionEquipmentIdentifiersSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const updatedCircuits = await circuitWriteService.updateSectionEquipmentIdentifiers(sectionId, parsed.data);
return res.json({ sectionId, circuits: updatedCircuits });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update section identifiers." });
}
}
+3 -16
View File
@@ -1,19 +1,5 @@
import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js";
export async function deleteCircuit(req: Request, res: Response) {
const { circuitId } = req.params;
if (typeof circuitId !== "string") {
return res.status(400).json({ error: "Invalid circuitId" });
}
try {
await circuitWriteService.deleteCircuit(circuitId);
return res.status(204).send();
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to delete circuit." });
}
}
import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
export async function getNextCircuitIdentifier(req: Request, res: Response) {
const { sectionId } = req.params;
@@ -21,7 +7,8 @@ export async function getNextCircuitIdentifier(req: Request, res: Response) {
return res.status(400).json({ error: "Invalid sectionId" });
}
try {
const nextIdentifier = await circuitWriteService.getNextIdentifier(sectionId);
const nextIdentifier =
await circuitNumberingService.getNextIdentifier(sectionId);
return res.json({ sectionId, nextIdentifier });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to get identifier." });
@@ -1,7 +1,13 @@
import type { Request, Response } from "express";
import { db } from "../../db/client.js";
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
import {
createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot,
} from "../../domain/models/distribution-board-structure-project-command.model.js";
import { createDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
const distributionBoardRepository = new DistributionBoardRepository(db);
@@ -26,9 +32,23 @@ export async function createDistributionBoard(req: Request, res: Response) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const board = distributionBoardRepository.createWithCircuitListAndDefaultSections(
const structure = createDistributionBoardStructureSnapshot(
projectId,
parsed.data.name
);
return res.status(201).json(board);
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Verteilung anlegen",
command:
createDistributionBoardInsertProjectCommand(structure),
});
return res.status(201).json({
...result,
distributionBoard: structure.distributionBoard,
});
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
@@ -1,4 +1,5 @@
import type { Request, Response } from "express";
import { listProjectRevisionsQuerySchema } from "../../shared/validation/project-history.schemas.js";
import { projectHistoryStore } from "../composition/project-command-stores.js";
export function getProjectHistory(req: Request, res: Response) {
@@ -13,3 +14,23 @@ export function getProjectHistory(req: Request, res: Response) {
}
return res.json(state);
}
export function listProjectRevisions(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
return res.status(400).json({ error: "Invalid projectId" });
}
const parsed = listProjectRevisionsQuerySchema.safeParse(req.query);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const page = projectHistoryStore.listRevisions({
projectId,
...parsed.data,
});
if (!page) {
return res.status(404).json({ error: "Project not found" });
}
return res.json(page);
}
@@ -0,0 +1,130 @@
import type { NextFunction, Request, Response } from "express";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
import {
createNamedProjectSnapshotSchema,
restoreProjectSnapshotSchema,
} from "../../shared/validation/project-snapshot.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { projectSnapshotStore } from "../composition/project-snapshot-store.js";
export function listProjectSnapshots(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const snapshots = projectSnapshotStore.listByProject(projectId);
if (!snapshots) {
return res.status(404).json({ error: "Project not found" });
}
return res.json(snapshots);
}
export function createNamedProjectSnapshot(
req: Request,
res: Response,
next: NextFunction
) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const parsed = createNamedProjectSnapshotSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const snapshot = projectSnapshotStore.createNamed({
projectId,
...parsed.data,
});
if (!snapshot) {
return res.status(404).json({ error: "Project not found" });
}
return res.status(201).json(snapshot);
} catch (error) {
if (error instanceof ProjectRevisionConflictError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_REVISION_CONFLICT",
expectedRevision: error.expectedRevision,
currentRevision: error.actualRevision,
});
}
if (error instanceof ProjectSnapshotNameConflictError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_SNAPSHOT_NAME_CONFLICT",
snapshotName: error.snapshotName,
});
}
return next(error);
}
}
export function restoreProjectSnapshot(
req: Request,
res: Response,
next: NextFunction
) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const { snapshotId } = req.params;
if (typeof snapshotId !== "string" || !snapshotId.trim()) {
return res.status(400).json({ error: "Invalid snapshotId" });
}
const parsed = restoreProjectSnapshotSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const prepared = projectSnapshotStore.prepareRestore(
projectId,
snapshotId
);
if (!prepared) {
return res.status(404).json({ error: "Project snapshot not found" });
}
const result = projectCommandService.executeRestore({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: `Snapshot "${prepared.snapshot.name}" wiederherstellen`,
command: prepared.command,
});
return res.json({
snapshot: prepared.snapshot,
revision: result.revision,
history: result.history,
});
} catch (error) {
if (error instanceof ProjectRevisionConflictError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_REVISION_CONFLICT",
expectedRevision: error.expectedRevision,
currentRevision: error.actualRevision,
});
}
if (error instanceof ProjectStateConflictError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_STATE_CONFLICT",
});
}
return next(error);
}
}
function getProjectId(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
res.status(400).json({ error: "Invalid projectId" });
return null;
}
return projectId;
}
+18 -5
View File
@@ -4,6 +4,9 @@ import {
createProjectSchema,
updateProjectSettingsSchema,
} from "../../shared/validation/project-structure.schemas.js";
import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
const projectRepository = new ProjectRepository();
@@ -44,10 +47,20 @@ export async function updateProjectSettings(req: Request, res: Response) {
return res.status(400).json({ error: parsed.error.flatten() });
}
await projectRepository.updateSettings(projectId, parsed.data);
const row = await projectRepository.findById(projectId);
if (!row) {
return res.status(404).json({ error: "Project not found" });
const { expectedRevision, ...settings } = parsed.data;
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: "Projekteinstellungen bearbeiten",
command: createProjectSettingsUpdateProjectCommand(settings),
});
const project = await projectRepository.findById(projectId);
if (!project) {
throw new Error("Updated project could not be loaded.");
}
return res.json({ ...result, project });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
return res.json(row);
}
-4
View File
@@ -1,7 +1,5 @@
import express from "express";
import { circuitDeviceRowRouter } from "./routes/circuit-device-row.routes.js";
import { circuitRouter } from "./routes/circuit.routes.js";
import { circuitSectionRouter } from "./routes/circuit-section.routes.js";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { projectRouter } from "./routes/project.routes.js";
@@ -18,8 +16,6 @@ app.get("/health", (_req, res) => {
app.use("/api/projects", projectRouter);
app.use("/api", circuitRouter);
app.use("/api", circuitDeviceRowRouter);
app.use("/api", circuitSectionRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);
@@ -1,10 +0,0 @@
import { Router } from "express";
import {
moveCircuitDeviceRowsBulk,
moveCircuitDeviceRow,
} 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/move", moveCircuitDeviceRow);
@@ -1,12 +0,0 @@
import { Router } from "express";
import {
renumberCircuitSection,
reorderSectionCircuits,
updateSectionEquipmentIdentifiers,
} from "../controllers/circuit-section.controller.js";
export const circuitSectionRouter = Router();
circuitSectionRouter.post("/circuit-sections/:sectionId/renumber", renumberCircuitSection);
circuitSectionRouter.patch("/circuit-sections/:sectionId/circuits/reorder", reorderSectionCircuits);
circuitSectionRouter.patch("/circuit-sections/:sectionId/equipment-identifiers", updateSectionEquipmentIdentifiers);
-2
View File
@@ -1,11 +1,9 @@
import { Router } from "express";
import {
deleteCircuit,
getNextCircuitIdentifier,
} from "../controllers/circuit.controller.js";
export const circuitRouter = Router();
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
+16 -1
View File
@@ -13,12 +13,20 @@ import { listCircuitListsByProject } from "../controllers/circuit-list.controlle
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
import { getProjectHistory } from "../controllers/project-history.controller.js";
import {
getProjectHistory,
listProjectRevisions,
} from "../controllers/project-history.controller.js";
import {
executeProjectCommand,
redoProjectCommand,
undoProjectCommand,
} from "../controllers/project-command.controller.js";
import {
createNamedProjectSnapshot,
listProjectSnapshots,
restoreProjectSnapshot,
} from "../controllers/project-snapshot.controller.js";
export const projectRouter = Router();
@@ -26,9 +34,16 @@ projectRouter.get("/", listProjects);
projectRouter.post("/", createProject);
projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand);
projectRouter.post("/:projectId/history/undo", undoProjectCommand);
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
projectRouter.get("/:projectId/snapshots", listProjectSnapshots);
projectRouter.post("/:projectId/snapshots", createNamedProjectSnapshot);
projectRouter.post(
"/:projectId/snapshots/:snapshotId/restore",
restoreProjectSnapshot
);
projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
-48
View File
@@ -1,48 +0,0 @@
import { z } from "zod";
export const moveCircuitDeviceRowSchema = z
.object({
targetCircuitId: z.string().min(1).optional(),
targetSectionId: z.string().min(1).optional(),
createNewCircuit: z.boolean().optional(),
})
.refine(
(value) =>
Boolean(value.targetCircuitId) ||
(Boolean(value.targetSectionId) && value.createNewCircuit === true),
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
);
export const moveCircuitDeviceRowsBulkSchema = z
.object({
rowIds: z.array(z.string().min(1)).min(1),
targetCircuitId: z.string().min(1).optional(),
targetSectionId: z.string().min(1).optional(),
createNewCircuit: z.boolean().optional(),
})
.refine(
(value) =>
Boolean(value.targetCircuitId) ||
(Boolean(value.targetSectionId) && value.createNewCircuit === true),
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
);
export const reorderSectionCircuitsSchema = z.object({
orderedCircuitIds: z.array(z.string().min(1)).min(1),
});
export const updateSectionEquipmentIdentifiersSchema = z.object({
identifiers: z
.array(
z.object({
circuitId: z.string().min(1),
equipmentIdentifier: z.string().min(1),
})
)
.min(1),
});
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;
@@ -0,0 +1,8 @@
import { z } from "zod";
export const listProjectRevisionsQuerySchema = z
.object({
limit: z.coerce.number().int().min(1).max(100).default(25),
beforeRevision: z.coerce.number().int().positive().optional(),
})
.strict();
@@ -0,0 +1,16 @@
import { z } from "zod";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
export const createNamedProjectSnapshotSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(100),
description: z.string().trim().max(500).optional(),
})
.strict();
export const restoreProjectSnapshotSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
})
.strict();
@@ -1,4 +1,5 @@
import { z } from "zod";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
export const createProjectSchema = z.object({
name: z.string().min(1),
@@ -6,14 +7,20 @@ export const createProjectSchema = z.object({
threePhaseVoltageV: z.number().positive().optional(),
});
export const updateProjectSettingsSchema = z.object({
singlePhaseVoltageV: z.number().positive(),
threePhaseVoltageV: z.number().positive(),
});
export const updateProjectSettingsSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
singlePhaseVoltageV: z.number().positive(),
threePhaseVoltageV: z.number().positive(),
})
.strict();
export const createDistributionBoardSchema = z.object({
name: z.string().min(1),
});
export const createDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1),
})
.strict();
export const createFloorSchema = z.object({
name: z.string().min(1),
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildCircuitDeviceRowMoveAssignments,
} from "../src/frontend/utils/circuit-device-row-move-command.js";
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
} from "../src/frontend/types.js";
function row(
id: string,
sortOrder: number
): CircuitTreeDeviceRowDto {
return {
id,
sortOrder,
name: id,
displayName: id,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
rowTotalPower: 0.1,
};
}
function circuit(
id: string,
deviceRows: CircuitTreeDeviceRowDto[]
): CircuitTreeCircuitDto {
return {
id,
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: id,
sortOrder: 10,
isReserve: deviceRows.length === 0,
circuitTotalPower: 0,
deviceRows,
};
}
describe("circuit device-row move command builder", () => {
it("records exact sources and appends rows in selection order", () => {
const targetRows = [row("target-row", 20)];
const circuits = [
circuit("source-1", [row("row-1", 30)]),
circuit("source-2", [row("row-2", 10)]),
circuit("target", targetRows),
];
assert.deepEqual(
buildCircuitDeviceRowMoveAssignments({
circuits,
rowIds: ["row-2", "row-1"],
targetCircuitId: "target",
targetDeviceRows: targetRows,
}),
[
{
rowId: "row-2",
expectedCircuitId: "source-2",
expectedSortOrder: 10,
targetCircuitId: "target",
targetSortOrder: 30,
},
{
rowId: "row-1",
expectedCircuitId: "source-1",
expectedSortOrder: 30,
targetCircuitId: "target",
targetSortOrder: 40,
},
]
);
});
it("starts a generated target circuit at sort order ten", () => {
assert.deepEqual(
buildCircuitDeviceRowMoveAssignments({
circuits: [circuit("source", [row("row-1", 40)])],
rowIds: ["row-1"],
targetCircuitId: "generated-target",
targetDeviceRows: [],
}),
[
{
rowId: "row-1",
expectedCircuitId: "source",
expectedSortOrder: 40,
targetCircuitId: "generated-target",
targetSortOrder: 10,
},
]
);
});
it("omits unchanged assignments during a same-circuit reorder", () => {
const rows = [row("row-1", 10), row("row-2", 20)];
assert.deepEqual(
buildCircuitDeviceRowMoveAssignments({
circuits: [circuit("target", rows)],
rowIds: ["row-1", "row-2"],
targetCircuitId: "target",
targetDeviceRows: rows,
}),
[]
);
});
it("rejects duplicate or missing selected rows", () => {
const circuits = [circuit("source", [row("row-1", 10)])];
assert.throws(
() =>
buildCircuitDeviceRowMoveAssignments({
circuits,
rowIds: ["row-1", "row-1"],
targetCircuitId: "target",
targetDeviceRows: [],
}),
/mehrfach/
);
assert.throws(
() =>
buildCircuitDeviceRowMoveAssignments({
circuits,
rowIds: ["missing"],
targetCircuitId: "target",
targetDeviceRows: [],
}),
/nicht gefunden/
);
});
});
@@ -1,529 +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 { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { 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: "Reserve",
sortOrder: 10,
isReserve: 1,
})
.run();
return context;
}
function insertCircuit(
context: DatabaseContext,
input: {
id: string;
equipmentIdentifier: string;
sortOrder: number;
isReserve?: number;
}
) {
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
context.db
.insert(circuits)
.values({
id: input.id,
circuitListId: seedCircuit.circuitListId,
sectionId: seedCircuit.sectionId,
equipmentIdentifier: input.equipmentIdentifier,
displayName: input.equipmentIdentifier,
sortOrder: input.sortOrder,
isReserve: input.isReserve ?? 1,
})
.run();
}
function insertDeviceRow(
context: DatabaseContext,
input: {
id?: string;
circuitId?: string;
sortOrder?: number;
displayName?: string;
} = {}
) {
const rowId = input.id ?? "row-1";
const circuitId = input.circuitId ?? "circuit-1";
context.db
.insert(circuitDeviceRows)
.values({
id: rowId,
circuitId,
sortOrder: input.sortOrder ?? 10,
name: "Leuchte",
displayName: input.displayName ?? "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})
.run();
context.db
.update(circuits)
.set({ isReserve: 0 })
.where(eq(circuits.id, circuitId))
.run();
}
describe("circuit device-row transaction repository", () => {
it("commits a new device row and clears the circuit reserve status together", () => {
const context = createTestDatabase();
try {
const repository = new CircuitDeviceRowTransactionRepository(context.db);
const rowId = repository.createInCircuit({
circuitId: "circuit-1",
name: "Steckdose",
displayName: "Steckdose",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
});
const [row] = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, rowId))
.all();
const [circuit] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.all();
assert.equal(row.circuitId, "circuit-1");
assert.equal(row.sortOrder, 10);
assert.equal(circuit.isReserve, 0);
} finally {
context.close();
}
});
it("rolls back the row insert when clearing the reserve status fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_reserve_clear
BEFORE UPDATE OF is_reserve ON circuits
WHEN NEW.is_reserve = 0
BEGIN
SELECT RAISE(ABORT, 'forced reserve clear failure');
END;
`);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
assert.throws(
() =>
repository.createInCircuit({
circuitId: "circuit-1",
name: "Steckdose",
displayName: "Steckdose",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
}),
/forced reserve clear failure/
);
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
assert.equal(context.db.select().from(circuits).all()[0].isReserve, 1);
} finally {
context.close();
}
});
it("commits a new circuit and all initial device rows together", () => {
const context = createTestDatabase();
try {
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
const repository = new CircuitDeviceRowTransactionRepository(context.db);
const created = repository.createCircuitWithDeviceRows({
circuit: {
circuitListId: seedCircuit.circuitListId,
sectionId: seedCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Beleuchtung",
sortOrder: 20,
},
deviceRows: [
{
name: "Leuchte",
displayName: "Leuchte 1",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
sortOrder: 15,
},
{
name: "Leuchte",
displayName: "Leuchte 2",
quantity: 2,
powerPerUnit: 0.1,
simultaneityFactor: 0.8,
},
],
});
const [createdCircuit] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, created.circuitId))
.all();
const createdRows = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, created.circuitId))
.all()
.sort((left, right) => left.sortOrder - right.sortOrder);
assert.equal(createdCircuit.equipmentIdentifier, "-1F2");
assert.equal(createdCircuit.isReserve, 0);
assert.deepEqual(
createdRows.map((row) => [row.id, row.displayName, row.sortOrder]),
[
[created.rowIds[0], "Leuchte 1", 15],
[created.rowIds[1], "Leuchte 2", 20],
]
);
} finally {
context.close();
}
});
it("rolls back the circuit and earlier rows when an initial row insert fails", () => {
const context = createTestDatabase();
try {
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
context.sqlite.exec(`
CREATE TRIGGER fail_initial_device_row
BEFORE INSERT ON circuit_device_rows
WHEN NEW.name = 'Fail'
BEGIN
SELECT RAISE(ABORT, 'forced initial row failure');
END;
`);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
assert.throws(
() =>
repository.createCircuitWithDeviceRows({
circuit: {
circuitListId: seedCircuit.circuitListId,
sectionId: seedCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Rollback",
sortOrder: 20,
},
deviceRows: [
{
name: "First",
displayName: "Erste Zeile",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
name: "Fail",
displayName: "Fehlerhafte Zeile",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
],
}),
/forced initial row failure/
);
assert.equal(context.db.select().from(circuits).all().length, 1);
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
} finally {
context.close();
}
});
it("commits the last-row deletion and activates the circuit reserve status together", () => {
const context = createTestDatabase();
try {
insertDeviceRow(context);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
repository.deleteFromCircuit("row-1", "circuit-1");
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
assert.equal(context.db.select().from(circuits).all()[0].isReserve, 1);
} finally {
context.close();
}
});
it("rolls back the row deletion when activating the reserve status fails", () => {
const context = createTestDatabase();
try {
insertDeviceRow(context);
context.sqlite.exec(`
CREATE TRIGGER fail_reserve_activation
BEFORE UPDATE OF is_reserve ON circuits
WHEN NEW.is_reserve = 1
BEGIN
SELECT RAISE(ABORT, 'forced reserve activation failure');
END;
`);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
assert.throws(
() => repository.deleteFromCircuit("row-1", "circuit-1"),
/forced reserve activation failure/
);
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 1);
assert.equal(context.db.select().from(circuits).all()[0].isReserve, 0);
} finally {
context.close();
}
});
it("moves rows to an existing circuit and updates both reserve states together", () => {
const context = createTestDatabase();
try {
insertCircuit(context, {
id: "circuit-2",
equipmentIdentifier: "-1F2",
sortOrder: 20,
});
insertCircuit(context, {
id: "circuit-3",
equipmentIdentifier: "-1F3",
sortOrder: 30,
});
insertDeviceRow(context);
insertDeviceRow(context, {
id: "row-2",
circuitId: "circuit-2",
sortOrder: 10,
displayName: "Zweite Quellzeile",
});
insertDeviceRow(context, {
id: "target-row",
circuitId: "circuit-3",
sortOrder: 10,
displayName: "Bestehende Zielzeile",
});
const repository = new CircuitDeviceRowTransactionRepository(context.db);
const result = repository.moveRows({
rows: [
{ id: "row-1", expectedCircuitId: "circuit-1" },
{ id: "row-2", expectedCircuitId: "circuit-2" },
],
targetCircuitId: "circuit-3",
});
const movedRows = context.db
.select()
.from(circuitDeviceRows)
.all()
.filter((row) => row.id === "row-1" || row.id === "row-2")
.sort((left, right) => left.sortOrder - right.sortOrder);
const persistedCircuits = context.db.select().from(circuits).all();
const targetCircuit = persistedCircuits.find(
(circuit) => circuit.id === "circuit-3"
);
assert.deepEqual(result, {
movedRowIds: ["row-1", "row-2"],
targetCircuitId: "circuit-3",
createdCircuitId: undefined,
});
assert.deepEqual(
movedRows.map((row) => [row.id, row.circuitId, row.sortOrder]),
[
["row-1", "circuit-3", 20],
["row-2", "circuit-3", 30],
]
);
assert.equal(
persistedCircuits.find((circuit) => circuit.id === "circuit-1")?.isReserve,
1
);
assert.equal(
persistedCircuits.find((circuit) => circuit.id === "circuit-2")?.isReserve,
1
);
assert.equal(targetCircuit?.isReserve, 0);
} finally {
context.close();
}
});
it("rolls back an existing-target move when a reserve update fails", () => {
const context = createTestDatabase();
try {
insertCircuit(context, {
id: "circuit-2",
equipmentIdentifier: "-1F2",
sortOrder: 20,
});
insertDeviceRow(context);
context.sqlite.exec(`
CREATE TRIGGER fail_source_reserve_update
BEFORE UPDATE OF is_reserve ON circuits
WHEN NEW.id = 'circuit-1' AND NEW.is_reserve = 1
BEGIN
SELECT RAISE(ABORT, 'forced source reserve failure');
END;
`);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
assert.throws(
() =>
repository.moveRows({
rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }],
targetCircuitId: "circuit-2",
}),
/forced source reserve failure/
);
const [row] = context.db.select().from(circuitDeviceRows).all();
const persistedCircuits = context.db.select().from(circuits).all();
assert.equal(row.circuitId, "circuit-1");
assert.equal(
persistedCircuits.find((circuit) => circuit.id === "circuit-1")?.isReserve,
0
);
assert.equal(
persistedCircuits.find((circuit) => circuit.id === "circuit-2")?.isReserve,
1
);
} finally {
context.close();
}
});
it("creates a new target circuit and moves rows into it atomically", () => {
const context = createTestDatabase();
try {
insertDeviceRow(context);
const [sourceCircuit] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.all();
const repository = new CircuitDeviceRowTransactionRepository(context.db);
const result = repository.moveRows({
rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }],
createTargetCircuit: {
circuitListId: sourceCircuit.circuitListId,
sectionId: sourceCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Neues Ziel",
sortOrder: 20,
},
});
const [movedRow] = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.all();
const [createdCircuit] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, result.createdCircuitId!))
.all();
const [updatedSource] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.all();
assert.equal(result.targetCircuitId, result.createdCircuitId);
assert.equal(createdCircuit.equipmentIdentifier, "-1F2");
assert.equal(createdCircuit.isReserve, 0);
assert.equal(movedRow.circuitId, createdCircuit.id);
assert.equal(updatedSource.isReserve, 1);
} finally {
context.close();
}
});
it("rolls back a newly created target circuit when the move fails", () => {
const context = createTestDatabase();
try {
insertDeviceRow(context);
const [sourceCircuit] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.all();
context.sqlite.exec(`
CREATE TRIGGER fail_new_target_source_reserve
BEFORE UPDATE OF is_reserve ON circuits
WHEN NEW.id = 'circuit-1' AND NEW.is_reserve = 1
BEGIN
SELECT RAISE(ABORT, 'forced new-target move failure');
END;
`);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
assert.throws(
() =>
repository.moveRows({
rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }],
createTargetCircuit: {
circuitListId: sourceCircuit.circuitListId,
sectionId: sourceCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Rollback-Ziel",
sortOrder: 20,
},
}),
/forced new-target move failure/
);
const [row] = context.db.select().from(circuitDeviceRows).all();
const persistedCircuits = context.db.select().from(circuits).all();
assert.equal(row.circuitId, "circuit-1");
assert.equal(persistedCircuits.length, 1);
assert.equal(persistedCircuits[0].isReserve, 0);
} finally {
context.close();
}
});
});
+126
View File
@@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { loadCircuitEditorSnapshot } from "../src/frontend/utils/circuit-editor-history.js";
import {
getProjectHistory,
redoProjectCommand,
undoProjectCommand,
} from "../src/frontend/utils/api.js";
import type {
CircuitTreeResponseDto,
ProjectHistoryStateDto,
} from "../src/frontend/types.js";
function tree(currentRevision: number): CircuitTreeResponseDto {
return {
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "Liste",
currentRevision,
sections: [],
};
}
function history(currentRevision: number): ProjectHistoryStateDto {
return {
projectId: "project-1",
currentRevision,
undoDepth: currentRevision,
redoDepth: 0,
undoChangeSetId: currentRevision > 0 ? `change-${currentRevision}` : null,
redoChangeSetId: null,
};
}
describe("circuit editor history snapshot", () => {
it("returns tree and persistent history from the same project revision", async () => {
const snapshot = await loadCircuitEditorSnapshot(
async () => tree(4),
async () => history(4)
);
assert.equal(snapshot.tree.currentRevision, 4);
assert.equal(snapshot.history.undoDepth, 4);
});
it("retries when the project changes between the tree and history reads", async () => {
const treeRevisions = [4, 5];
const historyRevisions = [5, 5];
let attempts = 0;
const snapshot = await loadCircuitEditorSnapshot(
async () => tree(treeRevisions[attempts]),
async () => history(historyRevisions[attempts++])
);
assert.equal(attempts, 2);
assert.equal(snapshot.tree.currentRevision, 5);
assert.equal(snapshot.history.currentRevision, 5);
});
it("rejects a snapshot that remains inconsistent", async () => {
await assert.rejects(
() =>
loadCircuitEditorSnapshot(
async () => tree(4),
async () => history(5),
2
),
/während des Ladens geändert/
);
});
it("loads history and sends revision-safe undo/redo requests", async () => {
const requests: Array<{ url: string; body?: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const url = String(input);
requests.push({
url,
...(init?.body
? { body: JSON.parse(String(init.body)) as unknown }
: {}),
});
const currentRevision = requests.length - 1;
const state = history(currentRevision);
return new Response(
JSON.stringify(
init?.method === "POST"
? {
revision: {
revisionId: `revision-${currentRevision}`,
changeSetId: `change-${currentRevision}`,
projectId: "project-1",
revisionNumber: currentRevision,
createdAtIso: "2026-07-25T00:00:00.000Z",
},
history: state,
}
: state
),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
};
try {
await getProjectHistory("project-1");
await undoProjectCommand("project-1", 4);
await redoProjectCommand("project-1", 5);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{ url: "/api/projects/project-1/history" },
{
url: "/api/projects/project-1/history/undo",
body: { expectedRevision: 4 },
},
{
url: "/api/projects/project-1/history/redo",
body: { expectedRevision: 5 },
},
]);
});
});
@@ -0,0 +1,166 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildCircuitSectionRenumberAssignments,
} from "../src/frontend/utils/circuit-section-renumber-command.js";
import {
renumberCircuitSectionCommand,
} from "../src/frontend/utils/api.js";
import type {
CircuitTreeCircuitDto,
CircuitTreeSectionDto,
} from "../src/frontend/types.js";
function circuit(
id: string,
equipmentIdentifier: string,
sortOrder: number
): CircuitTreeCircuitDto {
return {
id,
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier,
sortOrder,
isReserve: true,
circuitTotalPower: 0,
deviceRows: [],
};
}
function section(
id: string,
prefix: string,
circuits: CircuitTreeCircuitDto[]
): CircuitTreeSectionDto {
return {
id,
key: id,
displayName: id,
prefix,
sortOrder: 10,
circuits: circuits.map((entry) => ({
...entry,
sectionId: id,
})),
};
}
describe("circuit section renumber command adapters", () => {
it("plans sequential identifiers and skips conflicts outside the section", () => {
const sections = [
section("target", "-1F", [
circuit("circuit-1", "-1F7", 10),
circuit("circuit-2", "-1F9", 20),
circuit("circuit-3", "-1F11", 30),
]),
section("other", "-2F", [
circuit("circuit-other", "-1F2", 10),
]),
];
assert.deepEqual(
buildCircuitSectionRenumberAssignments(sections, "target"),
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F7",
targetEquipmentIdentifier: "-1F1",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F9",
targetEquipmentIdentifier: "-1F3",
},
{
circuitId: "circuit-3",
expectedEquipmentIdentifier: "-1F11",
targetEquipmentIdentifier: "-1F4",
},
]
);
});
it("omits an already canonical section and rejects an unknown section", () => {
const sections = [
section("target", "-2F", [
circuit("circuit-1", "-2F1", 10),
circuit("circuit-2", "-2F2", 20),
]),
];
assert.deepEqual(
buildCircuitSectionRenumberAssignments(sections, "target"),
[]
);
assert.throws(
() =>
buildCircuitSectionRenumberAssignments(
sections,
"missing"
),
/nicht gefunden/
);
});
it("sends the complete assignment set through project history", async () => {
let requestBody: Record<string, unknown> | null = null;
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_input, init) => {
requestBody = 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: 1,
createdAtIso: "2026-07-25T00:00:00.000Z",
},
history: {
projectId: "project-1",
currentRevision: 1,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: "change-1",
redoChangeSetId: null,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
};
const assignments = [
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-2F9",
targetEquipmentIdentifier: "-2F1",
},
];
try {
await renumberCircuitSectionCommand(
"project-1",
0,
"section-1",
assignments,
"renumber"
);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requestBody, {
expectedRevision: 0,
description: "renumber",
command: {
schemaVersion: 1,
type: "circuit.renumber-section",
payload: {
sectionId: "section-1",
assignments,
},
},
});
});
});
@@ -0,0 +1,190 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
createCircuitSectionsReorderProjectCommand,
} from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
import {
buildCircuitSectionReorderAssignments,
} from "../src/frontend/utils/circuit-section-reorder-command.js";
import {
reorderCircuitSectionCommand,
reorderCircuitSectionsCommand,
} from "../src/frontend/utils/api.js";
import type { CircuitTreeCircuitDto } from "../src/frontend/types.js";
function circuit(
id: string,
sortOrder: number
): CircuitTreeCircuitDto {
return {
id,
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: id,
sortOrder,
isReserve: true,
circuitTotalPower: 0,
deviceRows: [],
};
}
describe("circuit section reorder command adapters", () => {
it("builds complete exact assignments for a requested order", () => {
const circuits = [
circuit("circuit-1", 10),
circuit("circuit-2", 20),
circuit("circuit-3", 30),
];
assert.deepEqual(
buildCircuitSectionReorderAssignments(circuits, [
"circuit-3",
"circuit-1",
"circuit-2",
]),
[
{
circuitId: "circuit-3",
expectedSortOrder: 30,
targetSortOrder: 10,
},
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 30,
},
]
);
});
it("omits unchanged orders and rejects incomplete orders", () => {
const circuits = [
circuit("circuit-1", 10),
circuit("circuit-2", 20),
];
assert.deepEqual(
buildCircuitSectionReorderAssignments(circuits, [
"circuit-1",
"circuit-2",
]),
[]
);
assert.throws(
() =>
buildCircuitSectionReorderAssignments(circuits, [
"circuit-1",
]),
/jeden Stromkreis/
);
});
it("validates atomic multi-section reorder envelopes", () => {
const assignments = [
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
];
const command = createCircuitSectionsReorderProjectCommand([
{ sectionId: "section-1", assignments },
{
sectionId: "section-2",
assignments: assignments.map((assignment) => ({
...assignment,
circuitId: `${assignment.circuitId}-other`,
})),
},
]);
assert.equal(command.type, "circuit.reorder-sections");
assert.throws(
() =>
createCircuitSectionsReorderProjectCommand([
{ sectionId: "section-1", assignments },
{ sectionId: "section-1", assignments },
]),
/duplicate section ids/
);
});
it("sends single and multi-section commands through project history", async () => {
const requests: Array<Record<string, unknown>> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_input, init) => {
requests.push(
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" } }
);
};
const assignments = [
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
];
try {
await reorderCircuitSectionCommand(
"project-1",
0,
"section-1",
assignments,
"single"
);
await reorderCircuitSectionsCommand(
"project-1",
1,
[{ sectionId: "section-1", assignments }],
"multiple"
);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(
requests.map((request) => {
const command = request.command as { type: string };
return [request.expectedRevision, command.type];
}),
[
[0, "circuit.reorder-section"],
[1, "circuit.reorder-sections"],
]
);
});
});
@@ -16,10 +16,12 @@ 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";
import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
interface TestFixture {
context: DatabaseContext;
sectionId: string;
secondSectionId: string;
foreignSectionId: string;
}
@@ -44,17 +46,20 @@ function createTestDatabase(): TestFixture {
"project-2",
"UV-02"
);
const section = context.db
const projectSections = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
.all();
const section = projectSections[0];
const secondSection = projectSections[1];
const foreignSection = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, foreignBoard.id))
.get();
assert.ok(section);
assert.ok(secondSection);
assert.ok(foreignSection);
context.db
@@ -107,6 +112,7 @@ function createTestDatabase(): TestFixture {
return {
context,
sectionId: section.id,
secondSectionId: secondSection.id,
foreignSectionId: foreignSection.id,
};
}
@@ -226,6 +232,197 @@ describe("circuit section reorder project-command repository", () => {
}
});
it("reorders multiple sections atomically with one undo step", () => {
const fixture = createTestDatabase();
try {
const firstCircuit = fixture.context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(firstCircuit);
fixture.context.db
.insert(circuits)
.values([
{
id: "circuit-4",
circuitListId: firstCircuit.circuitListId,
sectionId: fixture.secondSectionId,
equipmentIdentifier: "-2F1",
sortOrder: 10,
},
{
id: "circuit-5",
circuitListId: firstCircuit.circuitListId,
sectionId: fixture.secondSectionId,
equipmentIdentifier: "-2F2",
sortOrder: 20,
},
])
.run();
const store =
new CircuitSectionReorderProjectCommandRepository(
fixture.context.db
);
const command = createCircuitSectionsReorderProjectCommand([
{
sectionId: fixture.sectionId,
assignments: createReorderCommand(fixture.sectionId)
.payload.assignments,
},
{
sectionId: fixture.secondSectionId,
assignments: [
{
circuitId: "circuit-4",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-5",
expectedSortOrder: 20,
targetSortOrder: 10,
},
],
},
]);
const reordered = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
fixture.context.db
.select({ id: circuits.id, sortOrder: circuits.sortOrder })
.from(circuits)
.all()
.filter((circuit) => circuit.id !== "circuit-foreign")
.sort((left, right) => left.id.localeCompare(right.id)),
[
{ id: "circuit-1", sortOrder: 20 },
{ id: "circuit-2", sortOrder: 30 },
{ id: "circuit-3", sortOrder: 10 },
{ id: "circuit-4", sortOrder: 20 },
{ id: "circuit-5", sortOrder: 10 },
]
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
reordered.revision.changeSetId,
command: reordered.inverse,
});
assert.deepEqual(
fixture.context.db
.select({ id: circuits.id, sortOrder: circuits.sortOrder })
.from(circuits)
.all()
.filter((circuit) => circuit.id !== "circuit-foreign")
.sort((left, right) => left.id.localeCompare(right.id)),
[
{ id: "circuit-1", sortOrder: 10 },
{ id: "circuit-2", sortOrder: 20 },
{ id: "circuit-3", sortOrder: 30 },
{ id: "circuit-4", sortOrder: 10 },
{ id: "circuit-5", sortOrder: 20 },
]
);
assert.equal(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
)?.redoDepth,
1
);
} finally {
fixture.context.close();
}
});
it("rolls back every section when a later section is stale", () => {
const fixture = createTestDatabase();
try {
const firstCircuit = fixture.context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(firstCircuit);
fixture.context.db
.insert(circuits)
.values([
{
id: "circuit-4",
circuitListId: firstCircuit.circuitListId,
sectionId: fixture.secondSectionId,
equipmentIdentifier: "-2F1",
sortOrder: 10,
},
{
id: "circuit-5",
circuitListId: firstCircuit.circuitListId,
sectionId: fixture.secondSectionId,
equipmentIdentifier: "-2F2",
sortOrder: 20,
},
])
.run();
const command = createCircuitSectionsReorderProjectCommand([
{
sectionId: fixture.sectionId,
assignments: createReorderCommand(fixture.sectionId)
.payload.assignments,
},
{
sectionId: fixture.secondSectionId,
assignments: [
{
circuitId: "circuit-4",
expectedSortOrder: 999,
targetSortOrder: 20,
},
{
circuitId: "circuit-5",
expectedSortOrder: 20,
targetSortOrder: 10,
},
],
},
]);
assert.throws(
() =>
new CircuitSectionReorderProjectCommandRepository(
fixture.context.db
).execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
}),
/changed before/
);
assert.deepEqual(
getCircuitState(fixture.context).map(
(circuit) => circuit.sortOrder
),
[10, 20, 30, 10, 20]
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rejects incomplete, stale and foreign-section reorders", () => {
const fixture = createTestDatabase();
try {
@@ -1,214 +0,0 @@
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 { CircuitSectionTransactionRepository } from "../src/db/repositories/circuit-section-transaction.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { 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: "Stromkreis 1",
sortOrder: 10,
isReserve: 0,
},
{
id: "circuit-2",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F2",
displayName: "Stromkreis 2",
sortOrder: 20,
isReserve: 1,
},
{
id: "circuit-3",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F3",
displayName: "Stromkreis 3",
sortOrder: 30,
isReserve: 1,
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})
.run();
return context;
}
function listCircuits(context: DatabaseContext) {
return context.db
.select()
.from(circuits)
.orderBy(asc(circuits.id))
.all();
}
describe("circuit-section transaction repository", () => {
it("commits identifier swaps without violating the circuit-list uniqueness constraint", () => {
const context = createTestDatabase();
try {
const persisted = listCircuits(context);
const repository = new CircuitSectionTransactionRepository(context.db);
repository.updateEquipmentIdentifiers(
persisted[0].circuitListId,
[
{ id: "circuit-1", equipmentIdentifier: "-1F2" },
{ id: "circuit-2", equipmentIdentifier: "-1F1" },
],
persisted[0].sectionId
);
assert.deepEqual(
listCircuits(context).map((circuit) => circuit.equipmentIdentifier),
["-1F2", "-1F1", "-1F3"]
);
assert.equal(
context.db.select().from(circuitDeviceRows).all()[0].circuitId,
"circuit-1"
);
} finally {
context.close();
}
});
it("rolls back temporary and final identifiers when a later update fails", () => {
const context = createTestDatabase();
try {
const persisted = listCircuits(context);
context.sqlite.exec(`
CREATE TRIGGER fail_second_final_identifier
BEFORE UPDATE OF equipment_identifier ON circuits
WHEN OLD.id = 'circuit-2' AND NEW.equipment_identifier = '-1F1'
BEGIN
SELECT RAISE(ABORT, 'forced identifier failure');
END;
`);
const repository = new CircuitSectionTransactionRepository(context.db);
assert.throws(
() =>
repository.updateEquipmentIdentifiers(
persisted[0].circuitListId,
[
{ id: "circuit-1", equipmentIdentifier: "-1F2" },
{ id: "circuit-2", equipmentIdentifier: "-1F1" },
],
persisted[0].sectionId
),
/forced identifier failure/
);
assert.deepEqual(
listCircuits(context).map((circuit) => circuit.equipmentIdentifier),
["-1F1", "-1F2", "-1F3"]
);
} finally {
context.close();
}
});
it("commits a complete section reorder without changing identifiers", () => {
const context = createTestDatabase();
try {
const persisted = listCircuits(context);
const repository = new CircuitSectionTransactionRepository(context.db);
repository.updateSortOrders(persisted[0].sectionId, [
"circuit-3",
"circuit-1",
"circuit-2",
]);
assert.deepEqual(
listCircuits(context).map((circuit) => [
circuit.id,
circuit.sortOrder,
circuit.equipmentIdentifier,
]),
[
["circuit-1", 20, "-1F1"],
["circuit-2", 30, "-1F2"],
["circuit-3", 10, "-1F3"],
]
);
} finally {
context.close();
}
});
it("rolls back earlier sort orders when a later update fails", () => {
const context = createTestDatabase();
try {
const persisted = listCircuits(context);
context.sqlite.exec(`
CREATE TRIGGER fail_last_sort_order
BEFORE UPDATE OF sort_order ON circuits
WHEN OLD.id = 'circuit-2' AND NEW.sort_order = 30
BEGIN
SELECT RAISE(ABORT, 'forced sort failure');
END;
`);
const repository = new CircuitSectionTransactionRepository(context.db);
assert.throws(
() =>
repository.updateSortOrders(persisted[0].sectionId, [
"circuit-3",
"circuit-1",
"circuit-2",
]),
/forced sort failure/
);
assert.deepEqual(
listCircuits(context).map((circuit) => circuit.sortOrder),
[10, 20, 30]
);
} finally {
context.close();
}
});
});
-448
View File
@@ -1,448 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
describe("circuit write service rules", () => {
it("renumber uses safe bulk identifier update for swapped identifiers", async () => {
let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1", prefix: "-2F" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "cB", sectionId: "s1", equipmentIdentifier: "-2F2", sortOrder: 10, isReserve: 0 },
{ id: "cA", sectionId: "s1", equipmentIdentifier: "-2F1", sortOrder: 20, isReserve: 1 },
] as never[];
},
async listByCircuitList() {
return [{ id: "x1", sectionId: "s2", equipmentIdentifier: "-3F1" }] as never[];
},
} as never,
circuitSectionTransactionStore: {
updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
safeUpdatePayload = updates;
},
} as never,
});
const result = await service.renumberSection("s1");
assert.deepEqual(safeUpdatePayload, [
{ id: "cB", equipmentIdentifier: "-2F1" },
{ id: "cA", equipmentIdentifier: "-2F2" },
]);
assert.equal(result.length, 2);
});
it("renumber shifts forward/backward and respects other sections", async () => {
let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1", prefix: "-1F" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F5", sortOrder: 10, isReserve: 0 },
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-1F1", sortOrder: 20, isReserve: 0 },
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-1F9", sortOrder: 30, isReserve: 0 },
] as never[];
},
async listByCircuitList() {
return [{ id: "o1", sectionId: "s2", equipmentIdentifier: "-1F2" }] as never[];
},
} as never,
circuitSectionTransactionStore: {
updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
safeUpdatePayload = updates;
},
} as never,
});
await service.renumberSection("s1");
assert.deepEqual(safeUpdatePayload, [
{ id: "c2", equipmentIdentifier: "-1F1" },
{ id: "c1", equipmentIdentifier: "-1F3" },
{ id: "c3", equipmentIdentifier: "-1F4" },
]);
});
it("renumber handles gaps and keeps device rows untouched by identifier-only update path", async () => {
let safeCalled = 0;
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1", prefix: "-1F" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 },
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F5", sortOrder: 20, isReserve: 0 },
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-1F9", sortOrder: 30, isReserve: 0 },
] as never[];
},
async listByCircuitList() {
return [] as never[];
},
} as never,
circuitSectionTransactionStore: {
updateEquipmentIdentifiers() {
safeCalled += 1;
},
} as never,
deviceRowRepository: {
async update() {
throw new Error("device rows must not be touched");
},
} as never,
});
await service.renumberSection("s1");
assert.equal(safeCalled, 1);
});
it("moving a device row to another circuit preserves row and toggles reserve flags", async () => {
let transactionalMove:
| {
rows: Array<{ id: string; expectedCircuitId: string }>;
targetCircuitId?: string;
createTargetCircuit?: unknown;
}
| undefined;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return { id: "r1", circuitId: "c1" } as never;
},
} as never,
deviceRowTransactionStore: {
moveRows(input: typeof transactionalMove) {
transactionalMove = input;
return {
movedRowIds: input!.rows.map((row) => row.id),
targetCircuitId: input!.targetCircuitId!,
};
},
} as never,
circuitRepository: {
async findById(circuitId: string) {
if (circuitId === "c1") {
return {
id: "c1",
sectionId: "s1",
circuitListId: "l1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 0,
} as never;
}
return {
id: "c2",
sectionId: "s1",
circuitListId: "l1",
equipmentIdentifier: "-1F2",
sortOrder: 20,
isReserve: 1,
} as never;
},
} as never,
});
await service.moveDeviceRow("r1", { targetCircuitId: "c2" });
assert.deepEqual(transactionalMove, {
rows: [{ id: "r1", expectedCircuitId: "c1" }],
targetCircuitId: "c2",
createTargetCircuit: undefined,
});
});
it("moving a device row to placeholder creates a new circuit in target section", async () => {
let preparedTarget:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return { id: "r1", circuitId: "c1" } as never;
},
} as never,
deviceRowTransactionStore: {
moveRows(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) {
preparedTarget = input.createTargetCircuit;
return {
movedRowIds: input.rows.map((row) => row.id),
targetCircuitId: "c-new",
createdCircuitId: "c-new",
};
},
} as never,
circuitRepository: {
async findById(circuitId: string) {
if (circuitId === "c1") {
return {
id: "c1",
sectionId: "s1",
circuitListId: "l1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 0,
} as never;
}
return null as never;
},
async listBySection() {
return [{ sortOrder: 40 }] as never[];
},
} as never,
circuitSectionRepository: {
async findById() {
return { id: "s2", circuitListId: "l1", prefix: "-2F" } as never;
},
} as never,
numberingService: {
async getNextIdentifier() {
return "-2F8";
},
} as never,
});
await service.moveDeviceRow("r1", { targetSectionId: "s2", createNewCircuit: true });
assert.deepEqual(preparedTarget, {
circuitListId: "l1",
sectionId: "s2",
equipmentIdentifier: "-2F8",
displayName: "Neuer Stromkreis",
sortOrder: 50,
});
});
it("moving a device row to its current circuit remains a no-op", async () => {
let transactionalMoveCount = 0;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return { id: "r1", circuitId: "c1" } as never;
},
} as never,
deviceRowTransactionStore: {
moveRows() {
transactionalMoveCount += 1;
throw new Error("same-circuit move must not write");
},
} as never,
circuitRepository: {
async findById() {
return {
id: "c1",
sectionId: "s1",
circuitListId: "l1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 0,
} as never;
},
} as never,
});
const result = await service.moveDeviceRow("r1", { targetCircuitId: "c1" });
assert.equal(transactionalMoveCount, 0);
assert.equal(result?.id, "r1");
});
it("moving multiple device rows to a circuit preserves input order and toggles reserve", async () => {
let transactionalMove:
| {
rows: Array<{ id: string; expectedCircuitId: string }>;
targetCircuitId?: string;
createTargetCircuit?: unknown;
}
| undefined;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById(rowId: string) {
if (rowId === "r1") {
return { id: "r1", circuitId: "c1" } as never;
}
return { id: "r2", circuitId: "c2" } as never;
},
} as never,
deviceRowTransactionStore: {
moveRows(input: typeof transactionalMove) {
transactionalMove = input;
return {
movedRowIds: input!.rows.map((row) => row.id),
targetCircuitId: input!.targetCircuitId!,
};
},
} as never,
circuitRepository: {
async findById(circuitId: string) {
if (circuitId === "c1") {
return { id: "c1", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never;
}
if (circuitId === "c2") {
return { id: "c2", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 } as never;
}
return { id: "c3", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F3", sortOrder: 30, isReserve: 1 } as never;
},
} as never,
});
const result = await service.moveDeviceRowsBulk({ rowIds: ["r1", "r2"], targetCircuitId: "c3" });
assert.deepEqual(transactionalMove, {
rows: [
{ id: "r1", expectedCircuitId: "c1" },
{ id: "r2", expectedCircuitId: "c2" },
],
targetCircuitId: "c3",
createTargetCircuit: undefined,
});
assert.deepEqual(result, {
movedRowIds: ["r1", "r2"],
targetCircuitId: "c3",
});
});
it("moving multiple device rows to placeholder creates exactly one new circuit", async () => {
let transactionCount = 0;
let preparedTarget:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById(rowId: string) {
return { id: rowId, circuitId: rowId === "r1" ? "c1" : "c2" } as never;
},
} as never,
deviceRowTransactionStore: {
moveRows(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) {
transactionCount += 1;
preparedTarget = input.createTargetCircuit;
return {
movedRowIds: input.rows.map((row) => row.id),
targetCircuitId: "c-new",
createdCircuitId: "c-new",
};
},
} as never,
circuitRepository: {
async findById(circuitId: string) {
if (circuitId === "c1" || circuitId === "c2") {
return { id: circuitId, sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never;
}
return null as never;
},
async listBySection() {
return [{ sortOrder: 30 }] as never[];
},
} as never,
circuitSectionRepository: {
async findById() {
return { id: "s2", circuitListId: "l1", prefix: "-2F" } as never;
},
} as never,
numberingService: {
async getNextIdentifier() {
return "-2F8";
},
} as never,
});
const result = await service.moveDeviceRowsBulk({
rowIds: ["r1", "r2"],
targetSectionId: "s2",
createNewCircuit: true,
});
assert.equal(transactionCount, 1);
assert.deepEqual(preparedTarget, {
circuitListId: "l1",
sectionId: "s2",
equipmentIdentifier: "-2F8",
displayName: "Neuer Stromkreis",
sortOrder: 40,
});
assert.equal(result.createdCircuitId, "c-new");
});
it("reorders circuits inside one section without renumbering identifiers", async () => {
let safeReorder: { sectionId: string; circuitIds: string[] } | undefined;
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-2F7", sortOrder: 10, isReserve: 0 },
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-2F9", sortOrder: 20, isReserve: 0 },
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 },
] as never[];
},
} as never,
circuitSectionTransactionStore: {
updateSortOrders(sectionId: string, circuitIds: string[]) {
safeReorder = { sectionId, circuitIds };
},
} as never,
});
await service.reorderCircuitsInSection("s1", { orderedCircuitIds: ["c3", "c1", "c2"] });
assert.deepEqual(safeReorder, {
sectionId: "s1",
circuitIds: ["c3", "c1", "c2"],
});
});
it("safe section identifier bulk update validates full section set (undo safety)", async () => {
let safeCalled = false;
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1", prefix: "-1F" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 },
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 },
] as never[];
},
} as never,
circuitSectionTransactionStore: {
updateEquipmentIdentifiers() {
safeCalled = true;
},
} as never,
});
await service.updateSectionEquipmentIdentifiers("s1", {
identifiers: [
{ circuitId: "c1", equipmentIdentifier: "-1F2" },
{ circuitId: "c2", equipmentIdentifier: "-1F1" },
],
});
assert.equal(safeCalled, true);
});
});
@@ -0,0 +1,346 @@
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 { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.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 {
createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot,
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
import { createDistributionBoard } from "../src/frontend/utils/api.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: "Foreign project" },
])
.run();
return context;
}
function getStructureCounts(context: DatabaseContext) {
return {
boards: context.db.select().from(distributionBoards).all().length,
lists: context.db.select().from(circuitLists).all().length,
sections: context.db.select().from(circuitSections).all().length,
revisions: context.db.select().from(projectRevisions).all().length,
};
}
describe("distribution-board structure project command", () => {
it("builds the fixed setup and sends the expected frontend revision", async () => {
const structure = createDistributionBoardStructureSnapshot(
"project-1",
" UV-02 "
);
assert.equal(structure.distributionBoard.name, "UV-02");
assert.equal(
structure.circuitList.name,
"UV-02 Stromkreisliste"
);
assert.deepEqual(
structure.sections.map((section) => [
section.key,
section.prefix,
]),
[
["lighting", "-1F"],
["single_phase", "-2F"],
["three_phase", "-3F"],
["unassigned", "-UF"],
]
);
assert.throws(
() =>
createDistributionBoardInsertProjectCommand({
...structure,
sections: structure.sections.slice(0, 3),
}),
/incomplete/
);
const requests: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
body: JSON.parse(String(init?.body)),
});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await createDistributionBoard("project-1", "UV-02", 7);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
url: "/api/projects/project-1/distribution-boards",
body: { name: "UV-02", expectedRevision: 7 },
},
]);
});
it("creates stable structure and supports persisted undo and redo", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(
context.db
);
const history = new ProjectHistoryRepository(context.db);
const structure = createDistributionBoardStructureSnapshot(
"project-1",
"UV-02"
);
const command =
createDistributionBoardInsertProjectCommand(structure);
const forward = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(getStructureCounts(context), {
boards: 1,
lists: 1,
sections: 4,
revisions: 1,
});
const undoStep = history.getNextCommand("project-1", "undo");
assert.ok(undoStep);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: forward.inverse,
});
assert.deepEqual(getStructureCounts(context), {
boards: 0,
lists: 0,
sections: 0,
revisions: 2,
});
const redoStep = history.getNextCommand("project-1", "redo");
assert.ok(redoStep);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: redoStep.changeSetId,
command,
});
assert.deepEqual(getStructureCounts(context), {
boards: 1,
lists: 1,
sections: 4,
revisions: 3,
});
assert.ok(
context.db
.select()
.from(distributionBoards)
.where(
eq(
distributionBoards.id,
structure.distributionBoard.id
)
)
.get()
);
} finally {
context.close();
}
});
it("rejects foreign, stale and changed structures without partial writes", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(
context.db
);
const foreign = createDistributionBoardInsertProjectCommand(
createDistributionBoardStructureSnapshot(
"project-2",
"UV fremd"
)
);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: foreign,
}),
/does not belong/
);
const structure = createDistributionBoardStructureSnapshot(
"project-1",
"UV-02"
);
const command =
createDistributionBoardInsertProjectCommand(structure);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command,
}),
ProjectRevisionConflictError
);
assert.deepEqual(getStructureCounts(context), {
boards: 0,
lists: 0,
sections: 0,
revisions: 0,
});
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
context.db
.update(distributionBoards)
.set({ name: "Direkt geändert" })
.where(eq(distributionBoards.id, structure.distributionBoard.id))
.run();
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo")?.changeSetId,
command: inserted.inverse,
}),
/changed before deletion/
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
1
);
} finally {
context.close();
}
});
it("refuses populated deletion and rolls back late history failures", () => {
const populatedContext = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(
populatedContext.db
);
const structure = createDistributionBoardStructureSnapshot(
"project-1",
"UV-02"
);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardInsertProjectCommand(structure),
});
populatedContext.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: structure.circuitList.id,
sectionId: structure.sections[0].id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
})
.run();
const undoStep = new ProjectHistoryRepository(
populatedContext.db
).getNextCommand("project-1", "undo");
assert.ok(undoStep);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: inserted.inverse,
}),
/populated/
);
} finally {
populatedContext.close();
}
const rollbackContext = createTestDatabase();
try {
rollbackContext.sqlite.exec(`
CREATE TRIGGER fail_distribution_board_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced distribution-board history failure');
END;
`);
const repository =
new DistributionBoardStructureProjectCommandRepository(
rollbackContext.db
);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createDistributionBoardInsertProjectCommand(
createDistributionBoardStructureSnapshot(
"project-1",
"UV rollback"
)
),
}),
/forced distribution-board history failure/
);
assert.deepEqual(getStructureCounts(rollbackContext), {
boards: 0,
lists: 0,
sections: 0,
revisions: 0,
});
} finally {
rollbackContext.close();
}
});
});
+143
View File
@@ -15,10 +15,13 @@ import { CircuitSectionReorderProjectCommandRepository } from "../src/db/reposit
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 { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.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 { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-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";
@@ -36,11 +39,17 @@ import {
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 { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-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 {
createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot,
} from "../src/domain/models/distribution-board-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 { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext {
@@ -95,11 +104,14 @@ function createService(context: DatabaseContext) {
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db),
new DistributionBoardStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectDeviceStructureProjectCommandRepository(context.db),
new ProjectDeviceProjectCommandRepository(context.db),
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
new ProjectSettingsProjectCommandRepository(context.db),
new ProjectStateRestoreCommandRepository(context.db),
new ProjectHistoryRepository(context.db)
);
}
@@ -764,6 +776,34 @@ describe("project command service", () => {
{ id: "circuit-2", sortOrder: 20 },
]
);
const multiSection = createService(context).executeUser({
projectId: "project-1",
expectedRevision: 2,
command: createCircuitSectionsReorderProjectCommand([
{
sectionId: firstCircuit.sectionId,
assignments:
createCircuitSectionReorderProjectCommand(
firstCircuit.sectionId,
[
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
]
).payload.assignments,
},
]),
});
assert.equal(multiSection.history.currentRevision, 3);
assert.equal(multiSection.history.redoDepth, 0);
} finally {
context.close();
}
@@ -893,6 +933,109 @@ describe("project command service", () => {
}
});
it("dispatches project settings updates through persistent history", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const updated = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createProjectSettingsUpdateProjectCommand({
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
});
assert.equal(updated.history.currentRevision, 1);
assert.deepEqual(
context.db
.select({
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
})
.from(projects)
.where(eq(projects.id, "project-1"))
.get(),
{
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}
);
const undone = service.undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(undone.history.currentRevision, 2);
assert.deepEqual(
context.db
.select({
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
})
.from(projects)
.where(eq(projects.id, "project-1"))
.get(),
{
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
}
);
} finally {
context.close();
}
});
it("dispatches distribution-board setup and its persisted inverse", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const structure = createDistributionBoardStructureSnapshot(
"project-1",
"UV-02"
);
const created = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command:
createDistributionBoardInsertProjectCommand(structure),
});
assert.equal(created.history.currentRevision, 1);
assert.ok(
context.db
.select()
.from(circuitSections)
.where(
eq(
circuitSections.circuitListId,
structure.circuitList.id
)
)
.get()
);
const undone = createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(undone.history.currentRevision, 2);
assert.equal(
context.db
.select()
.from(circuitSections)
.where(
eq(
circuitSections.circuitListId,
structure.circuitList.id
)
)
.all().length,
0
);
} finally {
context.close();
}
});
it("rejects unsupported and malformed commands before domain writes", () => {
const context = createTestDatabase();
try {
+38
View File
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { listProjectRevisionsQuerySchema } from "../src/shared/validation/project-history.schemas.js";
describe("project revision timeline query", () => {
it("applies a bounded default page size", () => {
assert.deepEqual(
listProjectRevisionsQuerySchema.parse({}),
{ limit: 25 }
);
assert.deepEqual(
listProjectRevisionsQuerySchema.parse({
limit: "100",
beforeRevision: "42",
}),
{
limit: 100,
beforeRevision: 42,
}
);
});
it("rejects invalid cursors, page sizes and unknown query fields", () => {
for (const query of [
{ limit: "0" },
{ limit: "101" },
{ limit: "1.5" },
{ beforeRevision: "0" },
{ beforeRevision: "not-a-number" },
{ unexpected: "value" },
]) {
assert.equal(
listProjectRevisionsQuerySchema.safeParse(query).success,
false
);
}
});
});
+144
View File
@@ -83,6 +83,150 @@ function getRowQuantity(context: DatabaseContext) {
}
describe("project history repository", () => {
it("lists revision metadata in stable descending pages without payloads", () => {
const context = createTestDatabase();
try {
const circuitsStore = new CircuitProjectCommandRepository(context.db);
const rowsStore = new CircuitDeviceRowProjectCommandRepository(context.db);
const first = circuitsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
actorId: "planner-1",
description: "Stromkreis umbenennen",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Neu",
}),
});
const second = rowsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
description: "Anzahl ändern",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
}),
});
const third = rowsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
description: "Anzahl zurücknehmen",
historyTargetChangeSetId: second.revision.changeSetId,
command: second.inverse,
});
const history = new ProjectHistoryRepository(context.db);
assert.deepEqual(
history.listRevisions({
projectId: "project-1",
limit: 2,
}),
{
projectId: "project-1",
currentRevision: 3,
revisions: [
{
revisionId: third.revision.revisionId,
changeSetId: third.revision.changeSetId,
revisionNumber: 3,
createdAtIso: third.revision.createdAtIso,
actorId: null,
source: "undo",
description: "Anzahl zurücknehmen",
commandType: "circuit-device-row.update",
payloadSchemaVersion: 1,
},
{
revisionId: second.revision.revisionId,
changeSetId: second.revision.changeSetId,
revisionNumber: 2,
createdAtIso: second.revision.createdAtIso,
actorId: null,
source: "user",
description: "Anzahl ändern",
commandType: "circuit-device-row.update",
payloadSchemaVersion: 1,
},
],
nextBeforeRevision: 2,
}
);
assert.deepEqual(
history.listRevisions({
projectId: "project-1",
limit: 2,
beforeRevision: 2,
}),
{
projectId: "project-1",
currentRevision: 3,
revisions: [
{
revisionId: first.revision.revisionId,
changeSetId: first.revision.changeSetId,
revisionNumber: 1,
createdAtIso: first.revision.createdAtIso,
actorId: "planner-1",
source: "user",
description: "Stromkreis umbenennen",
commandType: "circuit.update",
payloadSchemaVersion: 1,
},
],
nextBeforeRevision: null,
}
);
} finally {
context.close();
}
});
it("lists an empty timeline and rejects invalid page inputs", () => {
const context = createTestDatabase();
try {
const history = new ProjectHistoryRepository(context.db);
assert.deepEqual(
history.listRevisions({
projectId: "project-1",
limit: 25,
}),
{
projectId: "project-1",
currentRevision: 0,
revisions: [],
nextBeforeRevision: null,
}
);
assert.equal(
history.listRevisions({
projectId: "missing",
limit: 25,
}),
null
);
assert.throws(
() =>
history.listRevisions({
projectId: "project-1",
limit: 0,
}),
/between 1 and 100/
);
assert.throws(
() =>
history.listRevisions({
projectId: "project-1",
limit: 25,
beforeRevision: 0,
}),
/positive integer/
);
} finally {
context.close();
}
});
it("persists project-wide undo and redo stacks across repository instances", () => {
const context = createTestDatabase();
try {
+115
View File
@@ -11,8 +11,10 @@ import {
ProjectRevisionConflictError,
ProjectRevisionRepository,
} from "../src/db/repositories/project-revision.repository.js";
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
import { projects } from "../src/db/schema/projects.js";
function createTestDatabase(): DatabaseContext {
@@ -157,4 +159,117 @@ describe("project revision repository", () => {
context.close();
}
});
it("captures and retains automatic snapshots without deleting named snapshots", () => {
const context = createTestDatabase();
try {
const revisions = new ProjectRevisionRepository(context.db);
const snapshots = new ProjectSnapshotRepository(context.db);
const named = snapshots.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: "Automatisch · Revision 25",
});
assert.ok(named);
for (
let expectedRevision = 0;
expectedRevision < 325;
expectedRevision += 1
) {
appendTestRevision(revisions, expectedRevision);
}
const stored = context.db
.select({
id: projectSnapshots.id,
kind: projectSnapshots.kind,
name: projectSnapshots.name,
sourceRevision: projectSnapshots.sourceRevision,
createdByActorId: projectSnapshots.createdByActorId,
})
.from(projectSnapshots)
.all();
const automatic = stored
.filter((snapshot) => snapshot.kind === "automatic")
.sort(
(left, right) =>
left.sourceRevision - right.sourceRevision
);
assert.equal(automatic.length, 12);
assert.deepEqual(
automatic.map((snapshot) => snapshot.sourceRevision),
[50, 75, 100, 125, 150, 175, 200, 225, 250, 275, 300, 325]
);
assert.equal(
automatic.every(
(snapshot) => snapshot.createdByActorId === "test-user"
),
true
);
assert.equal(
automatic.some(
(snapshot) =>
snapshot.name === "Automatisch · Revision 25"
),
false
);
assert.deepEqual(
stored.filter((snapshot) => snapshot.kind === "named"),
[
{
id: named.id,
kind: "named",
name: "Automatisch · Revision 25",
sourceRevision: 0,
createdByActorId: null,
},
]
);
} finally {
context.close();
}
});
it("rolls back revision 25 when automatic snapshot persistence fails", () => {
const context = createTestDatabase();
try {
const repository = new ProjectRevisionRepository(context.db);
for (
let expectedRevision = 0;
expectedRevision < 24;
expectedRevision += 1
) {
appendTestRevision(repository, expectedRevision);
}
context.sqlite.exec(`
CREATE TRIGGER fail_automatic_project_snapshot
BEFORE INSERT ON project_snapshots
WHEN NEW.kind = 'automatic'
BEGIN
SELECT RAISE(ABORT, 'forced automatic snapshot failure');
END;
`);
assert.throws(
() => appendTestRevision(repository, 24),
/forced automatic snapshot failure/
);
assert.equal(repository.getCurrentRevision("project-1"), 24);
assert.equal(
context.db.select().from(projectRevisions).all().length,
24
);
assert.equal(
context.db.select().from(projectChangeSets).all().length,
24
);
assert.equal(
context.db.select().from(projectSnapshots).all().length,
0
);
} finally {
context.close();
}
});
});
@@ -0,0 +1,248 @@
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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.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 { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
import { updateProjectSettings } from "../src/frontend/utils/api.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: "Testprojekt",
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
})
.run();
return context;
}
function getSettings(context: DatabaseContext) {
return context.db
.select({
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
currentRevision: projects.currentRevision,
})
.from(projects)
.where(eq(projects.id, "project-1"))
.get();
}
describe("project settings project-command repository", () => {
it("validates settings commands and sends the expected revision from the frontend", async () => {
assert.throws(
() =>
createProjectSettingsUpdateProjectCommand({
singlePhaseVoltageV: 0,
threePhaseVoltageV: 400,
}),
/invalid voltages/
);
const requests: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
body: JSON.parse(String(init?.body)),
});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await updateProjectSettings("project-1", 7, {
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
});
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
url: "/api/projects/project-1",
body: {
expectedRevision: 7,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
},
},
]);
});
it("updates both voltages and supports persisted undo and redo", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
const history = new ProjectHistoryRepository(context.db);
const command = createProjectSettingsUpdateProjectCommand({
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
});
const forward = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(getSettings(context), {
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
currentRevision: 1,
});
assert.deepEqual(forward.inverse.payload, {
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
});
const undoStep = history.getNextCommand("project-1", "undo");
assert.ok(undoStep);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: forward.inverse,
});
assert.deepEqual(getSettings(context), {
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
currentRevision: 2,
});
const redoStep = history.getNextCommand("project-1", "redo");
assert.ok(redoStep);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: redoStep.changeSetId,
command,
});
assert.deepEqual(getSettings(context), {
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
currentRevision: 3,
});
} finally {
context.close();
}
});
it("rejects no-op, invalid project and stale revision without partial writes", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
assert.throws(
() =>
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
}),
}),
/did not change/
);
assert.throws(
() =>
repository.executeUpdate({
projectId: "missing",
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
}),
/Project not found/
);
assert.throws(
() =>
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
}),
ProjectRevisionConflictError
);
assert.deepEqual(getSettings(context), {
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
currentRevision: 0,
});
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
it("rolls back both values and revision after a late history failure", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_project_settings_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced project settings history failure');
END;
`);
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
assert.throws(
() =>
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
}),
/forced project settings history failure/
);
assert.deepEqual(getSettings(context), {
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
currentRevision: 0,
});
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
});
+41
View File
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
automaticProjectSnapshotIntervalRevisions,
automaticProjectSnapshotRetentionCount,
shouldCaptureAutomaticProjectSnapshot,
} from "../src/domain/models/project-snapshot-policy.model.js";
describe("automatic project snapshot policy", () => {
it("captures every 25 revisions and retains twelve automatic snapshots", () => {
assert.equal(automaticProjectSnapshotIntervalRevisions, 25);
assert.equal(automaticProjectSnapshotRetentionCount, 12);
assert.equal(
shouldCaptureAutomaticProjectSnapshot(24, null),
false
);
assert.equal(
shouldCaptureAutomaticProjectSnapshot(25, null),
true
);
assert.equal(
shouldCaptureAutomaticProjectSnapshot(49, 25),
false
);
assert.equal(
shouldCaptureAutomaticProjectSnapshot(50, 25),
true
);
});
it("rejects invalid or inconsistent revision inputs", () => {
assert.throws(
() => shouldCaptureAutomaticProjectSnapshot(-1, null),
/non-negative integer/
);
assert.throws(
() => shouldCaptureAutomaticProjectSnapshot(25, 26),
/exceeds current revision/
);
});
});
+282
View File
@@ -0,0 +1,282 @@
import crypto from "node:crypto";
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 { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { floors } from "../src/db/schema/floors.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.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: "Snapshot-Projekt",
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
})
.run();
const board = new DistributionBoardRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const list = context.db
.select()
.from(circuitLists)
.where(eq(circuitLists.distributionBoardId, board.id))
.get();
assert.ok(list);
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, list.id))
.get();
assert.ok(section);
context.db
.insert(floors)
.values({
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 10,
})
.run();
context.db
.insert(rooms)
.values({
id: "room-1",
projectId: "project-1",
floorId: "floor-1",
roomNumber: "001",
roomName: "Technik",
})
.run();
context.db
.insert(projectDevices)
.values({
id: "device-1",
projectId: "project-1",
name: "Pumpe",
displayName: "Pumpe",
phaseType: "three_phase",
quantity: 1,
powerPerUnit: 2.5,
simultaneityFactor: 0.8,
cosPhi: 0.9,
voltageV: 400,
})
.run();
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: list.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Technik",
sortOrder: 10,
voltage: 230,
isReserve: 0,
})
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "device-1",
roomId: "room-1",
sortOrder: 10,
name: "Pumpe",
displayName: "Pumpe lokal",
phaseType: "three_phase",
quantity: 1,
powerPerUnit: 2.5,
simultaneityFactor: 0.8,
cosPhi: 0.9,
})
.run();
return context;
}
describe("project snapshot repository", () => {
it("captures, hashes and lists a complete named logical snapshot", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSnapshotRepository(context.db);
const created = repository.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: " Vor Ausschreibung ",
description: " Sicherer Stand ",
actorId: "planner-1",
});
assert.ok(created);
assert.equal(created.sourceRevision, 0);
assert.equal(created.schemaVersion, 1);
assert.equal(created.name, "Vor Ausschreibung");
assert.equal(created.description, "Sicherer Stand");
assert.equal(created.createdByActorId, "planner-1");
assert.match(created.payloadSha256, /^[a-f0-9]{64}$/);
assert.equal(
context.db
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, "project-1"))
.get()?.currentRevision,
0
);
const persisted = context.db
.select()
.from(projectSnapshots)
.where(eq(projectSnapshots.id, created.id))
.get();
assert.ok(persisted);
assert.equal(
crypto
.createHash("sha256")
.update(persisted.payloadJson)
.digest("hex"),
persisted.payloadSha256
);
const state = deserializeProjectStateSnapshot(
persisted.payloadJson
);
assert.equal(state.project.id, "project-1");
assert.equal(state.distributionBoards.length, 1);
assert.equal(state.circuitLists.length, 1);
assert.equal(
state.circuitSections.length,
context.db.select().from(circuitSections).all().length
);
assert.equal(state.circuits.length, 1);
assert.equal(state.circuits[0].deviceRows.length, 1);
assert.equal(
state.circuits[0].deviceRows[0].linkedProjectDeviceId,
"device-1"
);
assert.equal(state.projectDevices.length, 1);
assert.equal(state.floors.length, 1);
assert.equal(state.rooms.length, 1);
assert.deepEqual(repository.listByProject("project-1"), [
created,
]);
} finally {
context.close();
}
});
it("rejects stale revisions and duplicate names without partial rows", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSnapshotRepository(context.db);
assert.throws(
() =>
repository.createNamed({
projectId: "project-1",
expectedRevision: 1,
name: "Stale",
}),
ProjectRevisionConflictError
);
const created = repository.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: "Freigabe",
});
assert.ok(created);
assert.throws(
() =>
repository.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: " Freigabe ",
}),
ProjectSnapshotNameConflictError
);
assert.equal(
context.db.select().from(projectSnapshots).all().length,
1
);
} finally {
context.close();
}
});
it("distinguishes unknown projects from empty snapshot lists", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSnapshotRepository(context.db);
assert.deepEqual(repository.listByProject("project-1"), []);
assert.equal(repository.listByProject("missing"), null);
assert.equal(
repository.createNamed({
projectId: "missing",
expectedRevision: 0,
name: "Unbekannt",
}),
null
);
} finally {
context.close();
}
});
it("verifies stored payload integrity before preparing a restore", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSnapshotRepository(context.db);
const snapshot = repository.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: "Freigabe",
});
assert.ok(snapshot);
const prepared = repository.prepareRestore(
"project-1",
snapshot.id
);
assert.ok(prepared);
assert.equal(
prepared.command.payload.targetState.project.id,
"project-1"
);
context.db
.update(projectSnapshots)
.set({ payloadJson: `${JSON.stringify({})}\n` })
.where(eq(projectSnapshots.id, snapshot.id))
.run();
assert.throws(
() => repository.prepareRestore("project-1", snapshot.id),
/checksum verification failed/
);
assert.equal(
repository.prepareRestore("project-1", "missing"),
null
);
} finally {
context.close();
}
});
});
@@ -0,0 +1,461 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-project-command.repository.js";
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-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 { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { consumers } from "../src/db/schema/consumers.js";
import { floors } from "../src/db/schema/floors.js";
import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { ProjectStateConflictError } from "../src/domain/errors/project-state-conflict.error.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values({ id: "project-1", name: "Ausgangsprojekt" })
.run();
const board = new DistributionBoardRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const list = context.db
.select()
.from(circuitLists)
.where(eq(circuitLists.distributionBoardId, board.id))
.get();
assert.ok(list);
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, list.id))
.get();
assert.ok(section);
context.db
.insert(floors)
.values({
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 10,
})
.run();
context.db
.insert(rooms)
.values({
id: "room-1",
projectId: "project-1",
floorId: "floor-1",
roomNumber: "001",
roomName: "Technik",
})
.run();
context.db
.insert(projectDevices)
.values({
id: "device-1",
projectId: "project-1",
name: "Pumpe",
displayName: "Pumpe",
phaseType: "three_phase",
quantity: 1,
powerPerUnit: 2.5,
simultaneityFactor: 0.8,
})
.run();
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: list.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Ausgang",
sortOrder: 10,
isReserve: 0,
})
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "device-1",
roomId: "room-1",
sortOrder: 10,
name: "Pumpe",
displayName: "Pumpe",
quantity: 1,
powerPerUnit: 2.5,
simultaneityFactor: 0.8,
})
.run();
return context;
}
function createService(context: DatabaseContext) {
return new ProjectCommandService(
new CircuitProjectCommandRepository(context.db),
new CircuitDeviceRowProjectCommandRepository(context.db),
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db),
new DistributionBoardStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectDeviceStructureProjectCommandRepository(context.db),
new ProjectDeviceProjectCommandRepository(context.db),
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
new ProjectSettingsProjectCommandRepository(context.db),
new ProjectStateRestoreCommandRepository(context.db),
new ProjectHistoryRepository(context.db)
);
}
function changeCompleteProjectState(context: DatabaseContext) {
context.db
.update(projects)
.set({
name: "Geänderter Stand",
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
})
.where(eq(projects.id, "project-1"))
.run();
context.db
.update(circuits)
.set({ displayName: "Geänderter Stromkreis" })
.where(eq(circuits.id, "circuit-1"))
.run();
context.db
.insert(floors)
.values({
id: "floor-2",
projectId: "project-1",
name: "OG",
sortOrder: 20,
})
.run();
context.db
.insert(rooms)
.values({
id: "room-2",
projectId: "project-1",
floorId: "floor-2",
roomNumber: "101",
roomName: "Büro",
})
.run();
context.db
.insert(projectDevices)
.values({
id: "device-2",
projectId: "project-1",
name: "Leuchte",
displayName: "Leuchte",
phaseType: "single_phase",
quantity: 2,
powerPerUnit: 0.05,
simultaneityFactor: 1,
})
.run();
new DistributionBoardRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-02");
}
function seedUpgradeOnlyData(context: DatabaseContext) {
const circuit = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(circuit);
context.db
.insert(consumers)
.values({
id: "consumer-1",
projectId: "project-1",
distributionBoardId:
context.db.select().from(circuitLists).get()!
.distributionBoardId,
circuitListId: circuit.circuitListId,
projectDeviceId: "device-1",
roomId: "room-1",
name: "Legacy-Pumpe",
quantity: 1,
installedPowerPerUnitKw: 2.5,
demandFactor: 0.8,
})
.run();
context.db
.insert(legacyConsumerCircuitMigrations)
.values({
consumerId: "consumer-1",
circuitId: "circuit-1",
circuitDeviceRowId: "row-1",
circuitListId: circuit.circuitListId,
createdAtIso: "2026-07-25T00:00:00.000Z",
})
.run();
context.db
.insert(legacyConsumerMigrationReports)
.values({
id: "report-1",
circuitListId: circuit.circuitListId,
legacyConsumerCount: 1,
createdCircuitCount: 1,
createdDeviceRowCount: 1,
duplicateGroupedCount: 0,
generatedIdentifierCount: 0,
unassignedRowCount: 0,
warningsJson: "[]",
generatedIdentifiersJson: "[]",
duplicateGroupsJson: "[]",
createdAtIso: "2026-07-25T00:00:00.000Z",
})
.run();
}
describe("project state restore command", () => {
it("restores a complete snapshot as a revision and supports undo and redo", () => {
const context = createTestDatabase();
try {
const snapshots = new ProjectSnapshotRepository(context.db);
seedUpgradeOnlyData(context);
const snapshot = snapshots.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: "Freigabe",
});
assert.ok(snapshot);
const original = readProjectStateSnapshot(context.db, "project-1");
assert.ok(original);
changeCompleteProjectState(context);
const changed = readProjectStateSnapshot(context.db, "project-1");
assert.ok(changed);
assert.notEqual(changed.payloadSha256, original.payloadSha256);
const prepared = snapshots.prepareRestore(
"project-1",
snapshot.id
);
assert.ok(prepared);
const service = createService(context);
assert.throws(
() =>
service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: prepared.command,
}),
/stored server snapshot/
);
const restored = service.executeRestore({
projectId: "project-1",
expectedRevision: 0,
description: "Snapshot wiederherstellen",
command: prepared.command,
});
assert.equal(restored.revision.revisionNumber, 1);
assert.equal(restored.history.undoDepth, 1);
assert.equal(
readProjectStateSnapshot(context.db, "project-1")?.payloadSha256,
original.payloadSha256
);
assert.equal(
context.db.select().from(projectSnapshots).all().length,
1
);
assert.equal(
context.db
.select()
.from(legacyConsumerCircuitMigrations)
.all().length,
1
);
assert.equal(
context.db
.select()
.from(legacyConsumerMigrationReports)
.all().length,
1
);
assert.equal(
context.db
.select({ roomId: consumers.roomId })
.from(consumers)
.where(eq(consumers.id, "consumer-1"))
.get()?.roomId,
"room-1"
);
const undone = service.undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(undone.revision.revisionNumber, 2);
assert.equal(undone.history.redoDepth, 1);
assert.equal(
readProjectStateSnapshot(context.db, "project-1")?.payloadSha256,
changed.payloadSha256
);
const redone = service.redo({
projectId: "project-1",
expectedRevision: 2,
});
assert.equal(redone.revision.revisionNumber, 3);
assert.equal(
readProjectStateSnapshot(context.db, "project-1")?.payloadSha256,
original.payloadSha256
);
assert.deepEqual(
context.db
.select({ source: projectRevisions.source })
.from(projectRevisions)
.all()
.map((entry) => entry.source),
["restore", "undo", "redo"]
);
} finally {
context.close();
}
});
it("rejects a prepared restore after an unversioned state change", () => {
const context = createTestDatabase();
try {
const snapshots = new ProjectSnapshotRepository(context.db);
const snapshot = snapshots.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: "Freigabe",
});
assert.ok(snapshot);
changeCompleteProjectState(context);
const prepared = snapshots.prepareRestore(
"project-1",
snapshot.id
);
assert.ok(prepared);
context.db
.update(projects)
.set({ name: "Noch neuer" })
.where(eq(projects.id, "project-1"))
.run();
assert.throws(
() =>
createService(context).executeRestore({
projectId: "project-1",
expectedRevision: 0,
command: prepared.command,
}),
ProjectStateConflictError
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
assert.equal(
context.db
.select({ name: projects.name })
.from(projects)
.get()?.name,
"Noch neuer"
);
} finally {
context.close();
}
});
it("rolls back the complete replacement after a late history failure", () => {
const context = createTestDatabase();
try {
const snapshots = new ProjectSnapshotRepository(context.db);
const snapshot = snapshots.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: "Freigabe",
});
assert.ok(snapshot);
changeCompleteProjectState(context);
const changed = readProjectStateSnapshot(context.db, "project-1");
const prepared = snapshots.prepareRestore(
"project-1",
snapshot.id
);
assert.ok(changed);
assert.ok(prepared);
context.sqlite.exec(`
CREATE TRIGGER fail_restore_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced restore history failure');
END;
`);
assert.throws(
() =>
createService(context).executeRestore({
projectId: "project-1",
expectedRevision: 0,
command: prepared.command,
}),
/forced restore history failure/
);
assert.equal(
readProjectStateSnapshot(context.db, "project-1")?.payloadSha256,
changed.payloadSha256
);
assert.equal(
context.db
.select({ currentRevision: projects.currentRevision })
.from(projects)
.get()?.currentRevision,
0
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
});
+166
View File
@@ -0,0 +1,166 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
deserializeProjectStateSnapshot,
parseProjectStateSnapshot,
serializeProjectStateSnapshot,
} from "../src/domain/models/project-state-snapshot.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
function minimalSnapshot() {
return {
schemaVersion: 1 as const,
project: {
id: "project-1",
name: "Projekt",
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
},
distributionBoards: [],
circuitLists: [],
circuitSections: [],
circuits: [],
projectDevices: [],
floors: [],
rooms: [],
};
}
describe("project state snapshot model", () => {
it("round-trips a versioned logical project state", () => {
const snapshot = parseProjectStateSnapshot(minimalSnapshot());
assert.deepEqual(
deserializeProjectStateSnapshot(
serializeProjectStateSnapshot(snapshot)
),
snapshot
);
});
it("rejects duplicate ids and cross-project ownership", () => {
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
},
{
id: "board-1",
projectId: "project-1",
name: "UV 2",
},
],
}),
/duplicate distribution board ids/
);
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
floors: [
{
id: "floor-1",
projectId: "project-2",
name: "EG",
sortOrder: 10,
},
],
}),
/floor belongs to a different project/
);
});
it("rejects invalid references and inconsistent reserve circuits", () => {
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
rooms: [
{
id: "room-1",
projectId: "project-1",
floorId: "missing",
roomNumber: "001",
roomName: "Technik",
},
],
}),
/room floor reference is invalid/
);
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
circuits: [
{
id: "circuit-1",
circuitListId: "missing",
sectionId: "missing",
equipmentIdentifier: "-1F1",
displayName: null,
sortOrder: 10,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: null,
controlRequirement: null,
status: null,
isReserve: true,
remark: null,
deviceRows: [],
},
],
}),
/circuit list reference is invalid/
);
});
});
describe("named project snapshot request", () => {
it("normalizes bounded snapshot metadata", () => {
assert.deepEqual(
createNamedProjectSnapshotSchema.parse({
expectedRevision: 12,
name: " Freigabe ",
description: " Stand vor Ausschreibung ",
}),
{
expectedRevision: 12,
name: "Freigabe",
description: "Stand vor Ausschreibung",
}
);
});
it("rejects stale-shaped and excessive input", () => {
for (const input of [
{ expectedRevision: -1, name: "Stand" },
{ expectedRevision: 0, name: " " },
{ expectedRevision: 0, name: "x".repeat(101) },
{
expectedRevision: 0,
name: "Stand",
description: "x".repeat(501),
},
{
expectedRevision: 0,
name: "Stand",
unexpected: true,
},
]) {
assert.equal(
createNamedProjectSnapshotSchema.safeParse(input).success,
false
);
}
});
});
+183
View File
@@ -0,0 +1,183 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js";
import {
createNamedProjectSnapshot,
getProjectHistory,
listProjectRevisions,
listProjectSnapshots,
redoProjectCommand,
restoreProjectSnapshot,
undoProjectCommand,
} from "../src/frontend/utils/api.js";
import {
getProjectRevisionDescription,
getProjectRevisionSourceLabel,
getProjectSnapshotKindLabel,
mergeProjectRevisionPages,
} from "../src/frontend/utils/project-version-history.js";
function revision(
revisionNumber: number,
overrides: Partial<ProjectRevisionSummaryDto> = {}
): ProjectRevisionSummaryDto {
return {
revisionId: `revision-${revisionNumber}`,
changeSetId: `change-${revisionNumber}`,
revisionNumber,
createdAtIso: "2026-07-25T12:00:00.000Z",
actorId: null,
source: "user",
description: null,
commandType: "circuit.update",
payloadSchemaVersion: 1,
...overrides,
};
}
describe("project version history presentation", () => {
it("renders a compact collapsed German entry point", () => {
const markup = renderToStaticMarkup(
createElement(ProjectVersionHistory, {
projectId: "project-1",
currentRevision: 42,
onProjectStateChange: () => undefined,
})
);
assert.match(markup, /Versionen und Sicherungspunkte/);
assert.match(markup, /Aktuelle Revision 42/);
assert.match(markup, /Rückgängig/);
assert.match(markup, /Wiederholen/);
assert.match(markup, /Anzeigen/);
assert.doesNotMatch(markup, /Stand speichern/);
});
it("uses German source and command descriptions", () => {
assert.equal(getProjectRevisionSourceLabel("user"), "Bearbeitung");
assert.equal(getProjectRevisionSourceLabel("undo"), "Rückgängig");
assert.equal(getProjectRevisionSourceLabel("redo"), "Wiederholt");
assert.equal(
getProjectRevisionSourceLabel("restore"),
"Wiederherstellung"
);
assert.equal(getProjectRevisionSourceLabel("migration"), "Migration");
assert.equal(
getProjectRevisionDescription(revision(1)),
"Stromkreis bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(2, { description: " Eigene Beschreibung " })
),
"Eigene Beschreibung"
);
assert.equal(
getProjectRevisionDescription(
revision(3, { commandType: "future.command" })
),
"future.command"
);
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
assert.equal(
getProjectSnapshotKindLabel("automatic"),
"Automatisch"
);
});
it("merges paginated revisions without duplicates in descending order", () => {
assert.deepEqual(
mergeProjectRevisionPages(
[revision(5), revision(4)],
[revision(4), revision(3)]
).map((entry) => entry.revisionNumber),
[5, 4, 3]
);
});
});
describe("project version history API", () => {
it("uses the revision-safe timeline and snapshot routes", async () => {
const requests: Array<{
url: string;
method: string;
body: unknown;
}> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
method: init?.method ?? "GET",
body: init?.body ? JSON.parse(String(init.body)) : null,
});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await getProjectHistory("project-1");
await listProjectRevisions("project-1", {
limit: 10,
beforeRevision: 21,
});
await listProjectSnapshots("project-1");
await createNamedProjectSnapshot(
"project-1",
22,
"Planfreigabe",
"Vor Ausführung"
);
await restoreProjectSnapshot("project-1", "snapshot-1", 22);
await undoProjectCommand("project-1", 23);
await redoProjectCommand("project-1", 24);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
url: "/api/projects/project-1/history",
method: "GET",
body: null,
},
{
url: "/api/projects/project-1/history/revisions?limit=10&beforeRevision=21",
method: "GET",
body: null,
},
{
url: "/api/projects/project-1/snapshots",
method: "GET",
body: null,
},
{
url: "/api/projects/project-1/snapshots",
method: "POST",
body: {
expectedRevision: 22,
name: "Planfreigabe",
description: "Vor Ausführung",
},
},
{
url: "/api/projects/project-1/snapshots/snapshot-1/restore",
method: "POST",
body: { expectedRevision: 22 },
},
{
url: "/api/projects/project-1/history/undo",
method: "POST",
body: { expectedRevision: 23 },
},
{
url: "/api/projects/project-1/history/redo",
method: "POST",
body: { expectedRevision: 24 },
},
]);
});
});