Compare commits
10 Commits
6b6d2c2a42
...
53282e8c7c
| Author | SHA1 | Date | |
|---|---|---|---|
| 53282e8c7c | |||
| 0a51703315 | |||
| ca13efbfcb | |||
| bd5f4f925f | |||
| e4c7cf06e9 | |||
| 1e4dd26bb8 | |||
| 2d0aa11d9c | |||
| 296cb0f1c4 | |||
| b79faed320 | |||
| 903d977443 |
@@ -220,8 +220,15 @@ After saving, the row becomes linked to the new project device.
|
|||||||
|
|
||||||
## Undo / Redo
|
## Undo / Redo
|
||||||
|
|
||||||
Session-local undo/redo exists for structural and destructive operations.
|
Session-local UI undo/redo exists for structural and destructive operations.
|
||||||
Persistent project-wide undo/redo remains future work.
|
The server already persists immutable revisions and project-wide undo/redo
|
||||||
|
eligibility for the first commands. Public command, undo and redo endpoints
|
||||||
|
exist for Circuit and CircuitDeviceRow field updates as well as
|
||||||
|
Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and
|
||||||
|
the UI cutover remain future work. CircuitDeviceRow moves between existing
|
||||||
|
circuits and moves that create one new placeholder target circuit are also
|
||||||
|
persisted. The latter stores the complete empty target snapshot so undo can
|
||||||
|
restore the rows and remove only the unchanged generated circuit.
|
||||||
|
|
||||||
Required operations:
|
Required operations:
|
||||||
|
|
||||||
@@ -271,7 +278,7 @@ Users must be able to override sizing suggestions.
|
|||||||
|
|
||||||
## Current Deferred Work
|
## Current Deferred Work
|
||||||
|
|
||||||
- persistent project revisions and undo/redo
|
- complete persistent project command coverage and the undo/redo UI cutover
|
||||||
- named logical snapshots and restore
|
- named logical snapshots and restore
|
||||||
- Revit/CSV/IFCGUID round-trip
|
- Revit/CSV/IFCGUID round-trip
|
||||||
- full electrical sizing
|
- full electrical sizing
|
||||||
|
|||||||
@@ -5,6 +5,67 @@
|
|||||||
The circuit-first editor uses tree, circuit, row and project-device endpoints.
|
The circuit-first editor uses tree, circuit, row and project-device endpoints.
|
||||||
All paths below are mounted below `/api`. There is no Consumer application API.
|
All paths below are mounted below `/api`. There is no Consumer application API.
|
||||||
|
|
||||||
|
Project responses from `GET /projects` and `GET /projects/:projectId` include
|
||||||
|
`currentRevision`. Versioned project commands use this value for optimistic
|
||||||
|
concurrency checks.
|
||||||
|
|
||||||
|
### Project Commands and History
|
||||||
|
|
||||||
|
- `GET /projects/:projectId/history`
|
||||||
|
- returns `currentRevision`, `undoDepth`, `redoDepth` and the current top
|
||||||
|
change-set id for both persistent stacks
|
||||||
|
- `POST /projects/:projectId/commands`
|
||||||
|
- executes a supported versioned command as a new user revision
|
||||||
|
- body: `{ "expectedRevision": 0, "command": { ... } }`
|
||||||
|
- `POST /projects/:projectId/history/undo`
|
||||||
|
- `POST /projects/:projectId/history/redo`
|
||||||
|
- body: `{ "expectedRevision": 1 }`
|
||||||
|
- executes the eligible inverse or forward command as a new auditable
|
||||||
|
revision
|
||||||
|
|
||||||
|
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
|
||||||
|
`circuit.delete`, `circuit-device-row.update`,
|
||||||
|
`circuit-device-row.insert`, `circuit-device-row.delete` and
|
||||||
|
`circuit-device-row.move` plus
|
||||||
|
`circuit-device-row.move-with-new-circuit`, all with schema version `1`. Other
|
||||||
|
command types are rejected. Insert commands contain the complete entity or
|
||||||
|
circuit block with stable ids; delete commands include the expected parent
|
||||||
|
identity. Circuit snapshots contain zero, one or multiple complete device
|
||||||
|
rows. Move commands contain each row's expected and target circuit plus its
|
||||||
|
exact expected and target sort order. This makes deletion and moves undoable
|
||||||
|
without generating replacement identities or renumbering circuits. The editor
|
||||||
|
does not consume these endpoints yet.
|
||||||
|
|
||||||
|
`circuit-device-row.move` targets existing circuits in the same circuit list.
|
||||||
|
`circuit-device-row.move-with-new-circuit` atomically creates exactly one
|
||||||
|
explicitly identified empty target circuit and moves one or multiple existing
|
||||||
|
rows into it. Its inverse restores every row to its exact prior position and
|
||||||
|
deletes the generated circuit only if its fields and complete row set still
|
||||||
|
match the recorded state.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expectedRevision": 0,
|
||||||
|
"description": "Rename circuit",
|
||||||
|
"command": {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"type": "circuit.update",
|
||||||
|
"payload": {
|
||||||
|
"circuitId": "cir_1",
|
||||||
|
"changes": [
|
||||||
|
{ "field": "displayName", "value": "Sockets East" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A stale `expectedRevision` returns HTTP `409` with
|
||||||
|
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
|
||||||
|
returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||||
|
|
||||||
## Circuit-First Endpoints
|
## Circuit-First Endpoints
|
||||||
|
|
||||||
### Distribution Board Setup
|
### Distribution Board Setup
|
||||||
|
|||||||
@@ -62,9 +62,42 @@ liegt über einen Host-Mount außerhalb des Containers.
|
|||||||
6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise
|
6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise
|
||||||
Viewport soweit möglich wieder her.
|
Viewport soweit möglich wieder her.
|
||||||
|
|
||||||
Die React-Historie ist derzeit sitzungslokal. Persistente Projektrevisionen,
|
Die React-Historie ist derzeit sitzungslokal. Das Datenmodell besitzt bereits
|
||||||
optimistische Revisionsprüfungen und serverseitiges Undo/Redo sind die nächste
|
einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
|
||||||
Architekturphase.
|
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
|
||||||
|
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
|
||||||
|
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
|
||||||
|
einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und
|
||||||
|
bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze
|
||||||
|
angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren interne
|
||||||
|
Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und
|
||||||
|
Revision gemeinsam committen beziehungsweise zurückrollen können.
|
||||||
|
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
|
||||||
|
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
|
||||||
|
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
|
||||||
|
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
|
||||||
|
`GET /api/projects/:projectId/history` lesbar. Ein zentraler Dispatcher führt
|
||||||
|
die unterstützten Typen `circuit.update` und `circuit-device-row.update` über
|
||||||
|
öffentliche Command-, Undo- und Redo-Endpunkte aus. Zusätzlich sind
|
||||||
|
`circuit-device-row.insert` und `circuit-device-row.delete` als atomare
|
||||||
|
Strukturkommandos vorhanden. Beim Löschen wird die vollständige Zeile im
|
||||||
|
inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte
|
||||||
|
wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen
|
||||||
|
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
|
||||||
|
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
|
||||||
|
Betriebsmittelkennzeichen. Das Grid verwendet diese Endpunkte noch nicht; seine
|
||||||
|
sichtbare Historie bleibt daher sitzungslokal.
|
||||||
|
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
|
||||||
|
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
|
||||||
|
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
|
||||||
|
Reservewerte aller beteiligten Stromkreise werden atomar neu abgeleitet. Das
|
||||||
|
Kompositkommando `circuit-device-row.move-with-new-circuit` bildet auch das
|
||||||
|
Verschieben auf einen freien Platz ab: Ein Zielstromkreis mit stabiler UUID und
|
||||||
|
BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten
|
||||||
|
Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen
|
||||||
|
Felder und vollständiger Zeilenbestand unverändert sind. Circuit-Sortierung,
|
||||||
|
Neunummerierung und Synchronisierung müssen vor der Frontend-Umstellung noch
|
||||||
|
als persistente Commands modelliert werden.
|
||||||
|
|
||||||
## Projektgeräte
|
## Projektgeräte
|
||||||
|
|
||||||
|
|||||||
@@ -137,11 +137,67 @@ Completed foundation:
|
|||||||
- database backup and independent restore verification are automated
|
- database backup and independent restore verification are automated
|
||||||
- the Consumer application path is removed and stable domain IDs are preserved
|
- the Consumer application path is removed and stable domain IDs are preserved
|
||||||
- editor grid projection and safety rules are separated from React rendering
|
- editor grid projection and safety rules are separated from React rendering
|
||||||
|
- projects carry a monotonic `currentRevision` counter starting at zero
|
||||||
|
- project revision metadata and versioned forward/inverse change-set payloads
|
||||||
|
have separate persistence entities
|
||||||
|
- forward and inverse payloads store complete serialized command envelopes;
|
||||||
|
command type and schema version are derived from those envelopes rather than
|
||||||
|
duplicated caller metadata
|
||||||
|
- command serialization rejects non-finite numbers, `undefined`, non-plain
|
||||||
|
objects and cyclic payloads before a revision transaction starts
|
||||||
|
- the SQLite revision repository advances the project counter and stores both
|
||||||
|
history records atomically
|
||||||
|
- revision appends use an expected-revision compare-and-set and reject stale
|
||||||
|
callers without writing partial history
|
||||||
|
- a first internal `circuit.update` command validates project/list/section
|
||||||
|
ownership and equipment-identifier uniqueness inside its transaction
|
||||||
|
- circuit updates derive their inverse from the persisted pre-command row and
|
||||||
|
commit the domain update, inverse command, change set and revision together
|
||||||
|
- an internal `circuit-device-row.update` command applies the same boundary to
|
||||||
|
device fields, validates project-device and room ownership and records
|
||||||
|
automatically derived local override metadata in its normalized forward
|
||||||
|
command
|
||||||
|
- integration tests apply the generated inverse as a new `undo` revision and
|
||||||
|
verify rollback for stale revisions and late history failures on both update
|
||||||
|
command types
|
||||||
|
- project-scoped relational undo/redo stacks persist eligible original change
|
||||||
|
sets independently from immutable audit revisions
|
||||||
|
- stack transitions enforce LIFO order, keep undo/redo revisions out of the
|
||||||
|
eligibility stacks and clear the redo branch on a new user command
|
||||||
|
- domain writes, audit revisions and stack transitions share one transaction;
|
||||||
|
a forced stack failure rolls all of them back
|
||||||
|
- `GET /api/projects/:projectId/history` exposes current revision, stack depths
|
||||||
|
and top change-set ids
|
||||||
|
- a central application dispatcher executes the supported `circuit.update`
|
||||||
|
and `circuit-device-row.update` envelopes without coupling the domain service
|
||||||
|
to SQLite
|
||||||
|
- public command, undo and redo endpoints require an expected revision; undo
|
||||||
|
and redo reconstruct the eligible persisted inverse or forward command
|
||||||
|
- `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable
|
||||||
|
row ids, complete deleted-row snapshots and circuit reserve state through
|
||||||
|
atomic insert/delete, revision and stack transitions
|
||||||
|
- `circuit.insert` and `circuit.delete` persist a complete circuit block with
|
||||||
|
zero, one or multiple device rows; undo restores all ids and fields without
|
||||||
|
renumbering
|
||||||
|
- `circuit-device-row.move` stores exact expected and target circuit/sort
|
||||||
|
positions for one or multiple rows, recalculates all affected reserve states
|
||||||
|
and reconstructs a deterministic inverse across multiple source circuits
|
||||||
|
- `circuit-device-row.move-with-new-circuit` additionally records the complete
|
||||||
|
empty target-circuit snapshot; forward creation and all row moves share one
|
||||||
|
transaction, while undo restores the source positions and deletes only an
|
||||||
|
unchanged generated target
|
||||||
|
|
||||||
Remaining constraints before implementing history:
|
Remaining constraints before completing persistent history:
|
||||||
|
|
||||||
- model project-scoped command descriptions independently from React callbacks
|
- extend the concrete project-scoped command union and executors beyond the
|
||||||
- route all future history-enabled mutations through one project revision boundary
|
Circuit field/insert/delete and CircuitDeviceRow
|
||||||
|
field/insert/delete/move slices; the generic versioned command envelope is
|
||||||
|
complete
|
||||||
|
- route the remaining structural, synchronization and destructive domain
|
||||||
|
writes through the shared command, revision and stack transaction boundary
|
||||||
|
- replace the session-local frontend command stack only after server-side
|
||||||
|
command coverage is complete for structural, destructive and synchronization
|
||||||
|
operations
|
||||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||||
- keep database backup/restore checks separate from project history tests
|
- keep database backup/restore checks separate from project history tests
|
||||||
|
|
||||||
|
|||||||
@@ -354,14 +354,63 @@ Persist project-wide change history and restore points across reloads and applic
|
|||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
- add project-scoped monotonic revisions
|
- [x] add project-scoped monotonic revision storage
|
||||||
- record immutable server-side change sets with before/after state
|
- [x] add immutable append-only server-side change-set storage with versioned
|
||||||
- add optimistic revision checks for stale commands
|
forward/inverse payloads
|
||||||
- persist undo/redo eligibility on the server
|
- [x] add optimistic checks when appending a revision
|
||||||
|
- route domain writes and history records through one shared transaction
|
||||||
|
- define concrete serializable domain command descriptions and executors
|
||||||
|
outside React
|
||||||
|
- [x] persist undo/redo eligibility on the server
|
||||||
|
- [x] expose public command, undo and redo endpoints for the first supported
|
||||||
|
update command types
|
||||||
- add named and periodic logical project snapshots
|
- add named and periodic logical project snapshots
|
||||||
- restore a historical snapshot as a new revision
|
- restore a historical snapshot as a new revision
|
||||||
- keep database backups separate from logical history
|
- keep database backups separate from logical history
|
||||||
|
|
||||||
|
Implemented foundation:
|
||||||
|
|
||||||
|
- projects start at revision zero and advance monotonically
|
||||||
|
- each revision has one separately stored logical change set
|
||||||
|
- forward and inverse changes use complete, versioned JSON command envelopes
|
||||||
|
- invalid or lossy command payloads are rejected before persistence
|
||||||
|
- an internal typed `circuit.update` command generates its inverse from the
|
||||||
|
persisted pre-command state
|
||||||
|
- circuit update, inverse command, change set and revision share one SQLite
|
||||||
|
transaction with stale-command and late-failure rollback coverage
|
||||||
|
- an internal typed `circuit-device-row.update` command uses the same boundary,
|
||||||
|
records derived ProjectDevice override metadata and rejects cross-project
|
||||||
|
rows, device links and rooms
|
||||||
|
- relational project-wide undo/redo stacks persist eligible original change
|
||||||
|
sets, enforce LIFO order and clear redo on a new user command
|
||||||
|
- stack transitions are part of the domain/revision transaction and have
|
||||||
|
late-failure rollback coverage
|
||||||
|
- a read-only project history endpoint exposes revision, stack depths and top
|
||||||
|
change-set ids
|
||||||
|
- a central application dispatcher reconstructs persisted commands and exposes
|
||||||
|
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
|
||||||
|
field updates
|
||||||
|
- typed CircuitDeviceRow insert/delete commands preserve complete row snapshots
|
||||||
|
and stable ids; row mutation, circuit reserve state, revision and history
|
||||||
|
stack transition commit or roll back together
|
||||||
|
- typed Circuit insert/delete commands treat the circuit and all device rows as
|
||||||
|
one block, preserve every stable id and never renumber neighbouring circuits
|
||||||
|
- typed CircuitDeviceRow move commands persist exact old/new circuit and sort
|
||||||
|
positions for single or multi-row moves between existing circuits and derive
|
||||||
|
affected reserve states in the same transaction
|
||||||
|
- placeholder-target moves persist one complete generated circuit identity and
|
||||||
|
all row positions atomically; undo restores every source and removes only an
|
||||||
|
unchanged generated circuit, without renumbering
|
||||||
|
- revision metadata, change-set payloads and the project counter are committed
|
||||||
|
in one SQLite transaction
|
||||||
|
- a stale expected revision produces no history writes
|
||||||
|
- integration coverage verifies sequential revisions, stale-command rejection
|
||||||
|
and rollback after a forced late persistence failure
|
||||||
|
|
||||||
|
The current editor operations are not connected to this history boundary yet.
|
||||||
|
Undo/redo therefore remains session-local until the remaining tasks are
|
||||||
|
implemented.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
- undo/redo remains available after a page reload
|
- undo/redo remains available after a page reload
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/database-backup.test.ts",
|
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/database-backup.test.ts",
|
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "tsx scripts/db-backup.ts",
|
"db:backup": "tsx scripts/db-backup.ts",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE `project_change_sets` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_revision_id` text NOT NULL,
|
||||||
|
`command_type` text NOT NULL,
|
||||||
|
`payload_schema_version` integer NOT NULL,
|
||||||
|
`forward_payload_json` text NOT NULL,
|
||||||
|
`inverse_payload_json` text NOT NULL,
|
||||||
|
FOREIGN KEY (`project_revision_id`) REFERENCES `project_revisions`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `project_change_sets_revision_unique` ON `project_change_sets` (`project_revision_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `project_revisions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`revision_number` integer NOT NULL,
|
||||||
|
`created_at_iso` text NOT NULL,
|
||||||
|
`actor_id` text,
|
||||||
|
`source` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `project_revisions_project_number_unique` ON `project_revisions` (`project_id`,`revision_number`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `projects` ADD `current_revision` integer DEFAULT 0 NOT NULL;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE `project_history_stack_entries` (
|
||||||
|
`change_set_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`stack` text NOT NULL,
|
||||||
|
`position` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`change_set_id`) REFERENCES `project_change_sets`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
CONSTRAINT "project_history_stack_entries_stack_check" CHECK("project_history_stack_entries"."stack" in ('undo', 'redo'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `project_history_stack_entries_position_unique` ON `project_history_stack_entries` (`project_id`,`stack`,`position`);
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,20 @@
|
|||||||
"when": 1784832541202,
|
"when": 1784832541202,
|
||||||
"tag": "0011_project_device_canonical_fields",
|
"tag": "0011_project_device_canonical_fields",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1784833957929,
|
||||||
|
"tag": "0012_project_revision_foundation",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1784835703230,
|
||||||
|
"tag": "0013_project_history_stacks",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertCircuitDeviceRowMoveProjectCommand,
|
||||||
|
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand,
|
||||||
|
circuitDeviceRowMoveCommandType,
|
||||||
|
circuitDeviceRowMoveWithNewCircuitCommandType,
|
||||||
|
createCircuitDeviceRowMoveProjectCommand,
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand,
|
||||||
|
type CircuitDeviceRowMoveAssignment,
|
||||||
|
type CircuitDeviceRowMoveHistoryProjectCommand,
|
||||||
|
} from "../../domain/models/circuit-device-row-move-project-command.model.js";
|
||||||
|
import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
CircuitDeviceRowMoveProjectCommandStore,
|
||||||
|
ExecuteCircuitDeviceRowMoveCommandInput,
|
||||||
|
} from "../../domain/ports/circuit-device-row-move-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
interface PersistedMoveRow {
|
||||||
|
id: string;
|
||||||
|
circuitId: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CircuitDeviceRowMoveProjectCommandRepository
|
||||||
|
implements CircuitDeviceRowMoveProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteCircuitDeviceRowMoveCommandInput) {
|
||||||
|
this.assertSupportedCommand(input.command);
|
||||||
|
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const inverse = this.applyCommand(
|
||||||
|
tx,
|
||||||
|
input.projectId,
|
||||||
|
input.command
|
||||||
|
);
|
||||||
|
const revision = appendProjectRevision(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
expectedRevision: input.expectedRevision,
|
||||||
|
source: input.source,
|
||||||
|
description: input.description,
|
||||||
|
actorId: input.actorId,
|
||||||
|
forward: input.command,
|
||||||
|
inverse,
|
||||||
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
return { revision, inverse };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSupportedCommand(
|
||||||
|
command: CircuitDeviceRowMoveHistoryProjectCommand
|
||||||
|
) {
|
||||||
|
if (command.type === circuitDeviceRowMoveCommandType) {
|
||||||
|
assertCircuitDeviceRowMoveProjectCommand(command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
command.type ===
|
||||||
|
circuitDeviceRowMoveWithNewCircuitCommandType
|
||||||
|
) {
|
||||||
|
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
command
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported circuit device-row move command.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCommand(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
command: CircuitDeviceRowMoveHistoryProjectCommand
|
||||||
|
): CircuitDeviceRowMoveHistoryProjectCommand {
|
||||||
|
if (command.type === circuitDeviceRowMoveCommandType) {
|
||||||
|
const rowsById = this.loadExpectedRows(
|
||||||
|
database,
|
||||||
|
command.payload.moves
|
||||||
|
);
|
||||||
|
const circuitIds = this.collectCircuitIds(
|
||||||
|
command.payload.moves
|
||||||
|
);
|
||||||
|
this.assertExistingCircuitsInOneList(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
circuitIds
|
||||||
|
);
|
||||||
|
const inverse = createCircuitDeviceRowMoveProjectCommand(
|
||||||
|
this.reverseMoves(command.payload.moves, rowsById)
|
||||||
|
);
|
||||||
|
this.applyMoves(database, command.payload.moves);
|
||||||
|
this.updateReserveStates(database, circuitIds);
|
||||||
|
return inverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
command
|
||||||
|
);
|
||||||
|
const { targetCircuit, targetCircuitAction, moves } =
|
||||||
|
command.payload;
|
||||||
|
const rowsById = this.loadExpectedRows(database, moves);
|
||||||
|
this.assertCircuitLocation(database, projectId, targetCircuit);
|
||||||
|
|
||||||
|
if (targetCircuitAction === "create") {
|
||||||
|
const sourceCircuitIds = [
|
||||||
|
...new Set(moves.map((move) => move.expectedCircuitId)),
|
||||||
|
];
|
||||||
|
this.assertExistingCircuitsInOneList(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
sourceCircuitIds,
|
||||||
|
targetCircuit.circuitListId
|
||||||
|
);
|
||||||
|
this.assertTargetCircuitAvailable(database, targetCircuit);
|
||||||
|
this.insertTargetCircuit(database, targetCircuit);
|
||||||
|
|
||||||
|
const inverse =
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
"delete",
|
||||||
|
targetCircuit,
|
||||||
|
this.reverseMoves(moves, rowsById)
|
||||||
|
);
|
||||||
|
this.applyMoves(database, moves);
|
||||||
|
this.updateReserveStates(database, [
|
||||||
|
...sourceCircuitIds,
|
||||||
|
targetCircuit.id,
|
||||||
|
]);
|
||||||
|
return inverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertTargetCircuitUnchanged(
|
||||||
|
database,
|
||||||
|
targetCircuit,
|
||||||
|
moves.map((move) => move.rowId)
|
||||||
|
);
|
||||||
|
const destinationCircuitIds = [
|
||||||
|
...new Set(moves.map((move) => move.targetCircuitId)),
|
||||||
|
];
|
||||||
|
this.assertExistingCircuitsInOneList(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
destinationCircuitIds,
|
||||||
|
targetCircuit.circuitListId
|
||||||
|
);
|
||||||
|
const inverse =
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
"create",
|
||||||
|
targetCircuit,
|
||||||
|
this.reverseMoves(moves, rowsById)
|
||||||
|
);
|
||||||
|
this.applyMoves(database, moves);
|
||||||
|
this.updateReserveStates(database, [
|
||||||
|
targetCircuit.id,
|
||||||
|
...destinationCircuitIds,
|
||||||
|
]);
|
||||||
|
const deleted = database
|
||||||
|
.delete(circuits)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.id, targetCircuit.id),
|
||||||
|
eq(
|
||||||
|
circuits.circuitListId,
|
||||||
|
targetCircuit.circuitListId
|
||||||
|
),
|
||||||
|
eq(circuits.isReserve, 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (deleted.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Created target circuit changed before deletion."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return inverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadExpectedRows(
|
||||||
|
database: AppDatabase,
|
||||||
|
moves: CircuitDeviceRowMoveAssignment[]
|
||||||
|
) {
|
||||||
|
const rowIds = moves.map((move) => move.rowId);
|
||||||
|
const persistedRows = database
|
||||||
|
.select({
|
||||||
|
id: circuitDeviceRows.id,
|
||||||
|
circuitId: circuitDeviceRows.circuitId,
|
||||||
|
sortOrder: circuitDeviceRows.sortOrder,
|
||||||
|
})
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||||
|
.all();
|
||||||
|
if (persistedRows.length !== rowIds.length) {
|
||||||
|
throw new Error(
|
||||||
|
"One or more circuit device rows do not exist."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsById = new Map(
|
||||||
|
persistedRows.map((row) => [row.id, row])
|
||||||
|
);
|
||||||
|
for (const move of moves) {
|
||||||
|
const row = rowsById.get(move.rowId);
|
||||||
|
if (
|
||||||
|
!row ||
|
||||||
|
row.circuitId !== move.expectedCircuitId ||
|
||||||
|
row.sortOrder !== move.expectedSortOrder
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit device row changed before move execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rowsById;
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectCircuitIds(
|
||||||
|
moves: CircuitDeviceRowMoveAssignment[]
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
moves.flatMap((move) => [
|
||||||
|
move.expectedCircuitId,
|
||||||
|
move.targetCircuitId,
|
||||||
|
])
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertExistingCircuitsInOneList(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
circuitIds: string[],
|
||||||
|
expectedCircuitListId?: string
|
||||||
|
) {
|
||||||
|
const participatingCircuits = database
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
circuitListId: circuits.circuitListId,
|
||||||
|
projectId: circuitLists.projectId,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuits.circuitListId)
|
||||||
|
)
|
||||||
|
.where(inArray(circuits.id, circuitIds))
|
||||||
|
.all();
|
||||||
|
if (participatingCircuits.length !== circuitIds.length) {
|
||||||
|
throw new Error(
|
||||||
|
"One or more move circuits do not exist."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
participatingCircuits.some(
|
||||||
|
(circuit) => circuit.projectId !== projectId
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("Move circuit does not belong to project.");
|
||||||
|
}
|
||||||
|
const circuitListIds = new Set(
|
||||||
|
participatingCircuits.map(
|
||||||
|
(circuit) => circuit.circuitListId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
circuitListIds.size !== 1 ||
|
||||||
|
(expectedCircuitListId !== undefined &&
|
||||||
|
!circuitListIds.has(expectedCircuitListId))
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"All moved rows and targets must belong to one circuit list."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCircuitLocation(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
snapshot: CircuitSnapshot
|
||||||
|
) {
|
||||||
|
const list = database
|
||||||
|
.select({ id: circuitLists.id })
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitLists.id, snapshot.circuitListId),
|
||||||
|
eq(circuitLists.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!list) {
|
||||||
|
throw new Error("Circuit list does not belong to project.");
|
||||||
|
}
|
||||||
|
const section = database
|
||||||
|
.select({ id: circuitSections.id })
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitSections.id, snapshot.sectionId),
|
||||||
|
eq(
|
||||||
|
circuitSections.circuitListId,
|
||||||
|
snapshot.circuitListId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!section) {
|
||||||
|
throw new Error("Section does not belong to circuit list.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertTargetCircuitAvailable(
|
||||||
|
database: AppDatabase,
|
||||||
|
snapshot: CircuitSnapshot
|
||||||
|
) {
|
||||||
|
const existingCircuit = database
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, snapshot.id))
|
||||||
|
.get();
|
||||||
|
if (existingCircuit) {
|
||||||
|
throw new Error("Move target circuit id already exists.");
|
||||||
|
}
|
||||||
|
const duplicateIdentifier = database
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.circuitListId, snapshot.circuitListId),
|
||||||
|
eq(
|
||||||
|
circuits.equipmentIdentifier,
|
||||||
|
snapshot.equipmentIdentifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (duplicateIdentifier) {
|
||||||
|
throw new Error(
|
||||||
|
"Duplicate equipmentIdentifier in circuit list."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertTargetCircuit(
|
||||||
|
database: AppDatabase,
|
||||||
|
snapshot: CircuitSnapshot
|
||||||
|
) {
|
||||||
|
database
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: snapshot.id,
|
||||||
|
circuitListId: snapshot.circuitListId,
|
||||||
|
sectionId: snapshot.sectionId,
|
||||||
|
equipmentIdentifier: snapshot.equipmentIdentifier,
|
||||||
|
displayName: snapshot.displayName,
|
||||||
|
sortOrder: snapshot.sortOrder,
|
||||||
|
protectionType: snapshot.protectionType,
|
||||||
|
protectionRatedCurrent: snapshot.protectionRatedCurrent,
|
||||||
|
protectionCharacteristic: snapshot.protectionCharacteristic,
|
||||||
|
cableType: snapshot.cableType,
|
||||||
|
cableCrossSection: snapshot.cableCrossSection,
|
||||||
|
cableLength: snapshot.cableLength,
|
||||||
|
rcdAssignment: snapshot.rcdAssignment,
|
||||||
|
terminalDesignation: snapshot.terminalDesignation,
|
||||||
|
voltage: snapshot.voltage,
|
||||||
|
controlRequirement: snapshot.controlRequirement,
|
||||||
|
status: snapshot.status,
|
||||||
|
isReserve: 1,
|
||||||
|
remark: snapshot.remark,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertTargetCircuitUnchanged(
|
||||||
|
database: AppDatabase,
|
||||||
|
snapshot: CircuitSnapshot,
|
||||||
|
expectedRowIds: string[]
|
||||||
|
) {
|
||||||
|
const current = database
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, snapshot.id))
|
||||||
|
.get();
|
||||||
|
if (
|
||||||
|
!current ||
|
||||||
|
current.circuitListId !== snapshot.circuitListId ||
|
||||||
|
current.sectionId !== snapshot.sectionId ||
|
||||||
|
current.equipmentIdentifier !==
|
||||||
|
snapshot.equipmentIdentifier ||
|
||||||
|
current.displayName !== snapshot.displayName ||
|
||||||
|
current.sortOrder !== snapshot.sortOrder ||
|
||||||
|
current.protectionType !== snapshot.protectionType ||
|
||||||
|
current.protectionRatedCurrent !==
|
||||||
|
snapshot.protectionRatedCurrent ||
|
||||||
|
current.protectionCharacteristic !==
|
||||||
|
snapshot.protectionCharacteristic ||
|
||||||
|
current.cableType !== snapshot.cableType ||
|
||||||
|
current.cableCrossSection !== snapshot.cableCrossSection ||
|
||||||
|
current.cableLength !== snapshot.cableLength ||
|
||||||
|
current.rcdAssignment !== snapshot.rcdAssignment ||
|
||||||
|
current.terminalDesignation !==
|
||||||
|
snapshot.terminalDesignation ||
|
||||||
|
current.voltage !== snapshot.voltage ||
|
||||||
|
current.controlRequirement !==
|
||||||
|
snapshot.controlRequirement ||
|
||||||
|
current.status !== snapshot.status ||
|
||||||
|
current.remark !== snapshot.remark ||
|
||||||
|
Boolean(current.isReserve)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Created target circuit changed before command execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRowIds = database
|
||||||
|
.select({ id: circuitDeviceRows.id })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, snapshot.id))
|
||||||
|
.all()
|
||||||
|
.map((row) => row.id);
|
||||||
|
const expectedRowIdSet = new Set(expectedRowIds);
|
||||||
|
if (
|
||||||
|
currentRowIds.length !== expectedRowIds.length ||
|
||||||
|
currentRowIds.some((id) => !expectedRowIdSet.has(id))
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Created target circuit rows changed before command execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private reverseMoves(
|
||||||
|
moves: CircuitDeviceRowMoveAssignment[],
|
||||||
|
rowsById: Map<string, PersistedMoveRow>
|
||||||
|
) {
|
||||||
|
return moves.map((move) => {
|
||||||
|
const current = rowsById.get(move.rowId)!;
|
||||||
|
return {
|
||||||
|
rowId: move.rowId,
|
||||||
|
expectedCircuitId: move.targetCircuitId,
|
||||||
|
expectedSortOrder: move.targetSortOrder,
|
||||||
|
targetCircuitId: current.circuitId,
|
||||||
|
targetSortOrder: current.sortOrder,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyMoves(
|
||||||
|
database: AppDatabase,
|
||||||
|
moves: CircuitDeviceRowMoveAssignment[]
|
||||||
|
) {
|
||||||
|
for (const move of moves) {
|
||||||
|
const result = database
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({
|
||||||
|
circuitId: move.targetCircuitId,
|
||||||
|
sortOrder: move.targetSortOrder,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitDeviceRows.id, move.rowId),
|
||||||
|
eq(
|
||||||
|
circuitDeviceRows.circuitId,
|
||||||
|
move.expectedCircuitId
|
||||||
|
),
|
||||||
|
eq(
|
||||||
|
circuitDeviceRows.sortOrder,
|
||||||
|
move.expectedSortOrder
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit device row changed during move execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateReserveStates(
|
||||||
|
database: AppDatabase,
|
||||||
|
circuitIds: string[]
|
||||||
|
) {
|
||||||
|
for (const circuitId of new Set(circuitIds)) {
|
||||||
|
const remainingRow = database
|
||||||
|
.select({ id: circuitDeviceRows.id })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, circuitId))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
const updated = database
|
||||||
|
.update(circuits)
|
||||||
|
.set({ isReserve: remainingRow ? 0 : 1 })
|
||||||
|
.where(eq(circuits.id, circuitId))
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit changed during device-row move execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertCircuitDeviceRowUpdateProjectCommand,
|
||||||
|
createCircuitDeviceRowUpdateProjectCommand,
|
||||||
|
type CircuitDeviceRowUpdateField,
|
||||||
|
type CircuitDeviceRowUpdatePatch,
|
||||||
|
type CircuitDeviceRowUpdateValues,
|
||||||
|
} from "../../domain/models/circuit-device-row-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
CircuitDeviceRowProjectCommandStore,
|
||||||
|
ExecuteCircuitDeviceRowUpdateCommandInput,
|
||||||
|
} from "../../domain/ports/circuit-device-row-project-command.store.js";
|
||||||
|
import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { rooms } from "../schema/rooms.js";
|
||||||
|
import {
|
||||||
|
toCircuitDeviceRowPatchValues,
|
||||||
|
type CircuitDeviceRowPatchInput,
|
||||||
|
} from "./circuit-device-row.persistence.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||||
|
|
||||||
|
export class CircuitDeviceRowProjectCommandRepository
|
||||||
|
implements CircuitDeviceRowProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) {
|
||||||
|
assertCircuitDeviceRowUpdateProjectCommand(input.command);
|
||||||
|
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const current = tx
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, input.command.payload.rowId))
|
||||||
|
.get();
|
||||||
|
if (!current) {
|
||||||
|
throw new Error("Invalid device row id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const circuit = tx
|
||||||
|
.select({ circuitListId: circuits.circuitListId })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, current.circuitId))
|
||||||
|
.get();
|
||||||
|
const owningList = circuit
|
||||||
|
? tx
|
||||||
|
.select({ id: circuitLists.id })
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitLists.id, circuit.circuitListId),
|
||||||
|
eq(circuitLists.projectId, input.projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
: null;
|
||||||
|
if (!owningList) {
|
||||||
|
throw new Error("Circuit device row does not belong to project.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = Object.fromEntries(
|
||||||
|
input.command.payload.changes.map((change) => [
|
||||||
|
change.field,
|
||||||
|
change.value,
|
||||||
|
])
|
||||||
|
) as CircuitDeviceRowPatchInput;
|
||||||
|
this.assertLinkedProjectDevice(tx, input.projectId, patch);
|
||||||
|
this.assertRoom(tx, input.projectId, patch);
|
||||||
|
|
||||||
|
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
|
||||||
|
current,
|
||||||
|
patch
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
overriddenFields !== undefined &&
|
||||||
|
patch.overriddenFields === undefined
|
||||||
|
) {
|
||||||
|
patch.overriddenFields = overriddenFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appliedForward = createCircuitDeviceRowUpdateProjectCommand(
|
||||||
|
current.id,
|
||||||
|
patch as CircuitDeviceRowUpdatePatch
|
||||||
|
);
|
||||||
|
const inversePatch = Object.fromEntries(
|
||||||
|
appliedForward.payload.changes.map((change) => [
|
||||||
|
change.field,
|
||||||
|
getCircuitDeviceRowFieldValue(current, change.field),
|
||||||
|
])
|
||||||
|
) as CircuitDeviceRowUpdatePatch;
|
||||||
|
const inverse = createCircuitDeviceRowUpdateProjectCommand(
|
||||||
|
current.id,
|
||||||
|
inversePatch
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = tx
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set(toCircuitDeviceRowPatchValues(patch))
|
||||||
|
.where(eq(circuitDeviceRows.id, current.id))
|
||||||
|
.run();
|
||||||
|
if (update.changes !== 1) {
|
||||||
|
throw new Error("Circuit device row changed before command execution.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = appendProjectRevision(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
expectedRevision: input.expectedRevision,
|
||||||
|
source: input.source,
|
||||||
|
description: input.description,
|
||||||
|
actorId: input.actorId,
|
||||||
|
forward: appliedForward,
|
||||||
|
inverse,
|
||||||
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { revision, inverse };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertLinkedProjectDevice(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
patch: CircuitDeviceRowPatchInput
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!Object.prototype.hasOwnProperty.call(patch, "linkedProjectDeviceId") ||
|
||||||
|
patch.linkedProjectDeviceId === null ||
|
||||||
|
patch.linkedProjectDeviceId === undefined
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const device = database
|
||||||
|
.select({ id: projectDevices.id })
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectDevices.id, patch.linkedProjectDeviceId),
|
||||||
|
eq(projectDevices.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!device) {
|
||||||
|
throw new Error("Invalid linked project device id.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertRoom(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
patch: CircuitDeviceRowPatchInput
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!Object.prototype.hasOwnProperty.call(patch, "roomId") ||
|
||||||
|
patch.roomId === null ||
|
||||||
|
patch.roomId === undefined
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const room = database
|
||||||
|
.select({ id: rooms.id })
|
||||||
|
.from(rooms)
|
||||||
|
.where(and(eq(rooms.id, patch.roomId), eq(rooms.projectId, projectId)))
|
||||||
|
.get();
|
||||||
|
if (!room) {
|
||||||
|
throw new Error("Invalid room id.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCircuitDeviceRowFieldValue<
|
||||||
|
TField extends CircuitDeviceRowUpdateField,
|
||||||
|
>(
|
||||||
|
row: CircuitDeviceRow,
|
||||||
|
field: TField
|
||||||
|
): CircuitDeviceRowUpdateValues[TField] {
|
||||||
|
return row[field] as unknown as CircuitDeviceRowUpdateValues[TField];
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertCircuitDeviceRowDeleteProjectCommand,
|
||||||
|
assertCircuitDeviceRowInsertProjectCommand,
|
||||||
|
circuitDeviceRowDeleteCommandType,
|
||||||
|
circuitDeviceRowInsertCommandType,
|
||||||
|
createCircuitDeviceRowDeleteProjectCommand,
|
||||||
|
createCircuitDeviceRowInsertProjectCommand,
|
||||||
|
type CircuitDeviceRowSnapshot,
|
||||||
|
type CircuitDeviceRowStructureProjectCommand,
|
||||||
|
} from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
CircuitDeviceRowStructureProjectCommandStore,
|
||||||
|
ExecuteCircuitDeviceRowStructureCommandInput,
|
||||||
|
} from "../../domain/ports/circuit-device-row-structure-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import {
|
||||||
|
assertCircuitDeviceRowReferencesInProject,
|
||||||
|
toCircuitDeviceRowSnapshot,
|
||||||
|
} from "./circuit-device-row-structure.persistence.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
export class CircuitDeviceRowStructureProjectCommandRepository
|
||||||
|
implements CircuitDeviceRowStructureProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const inverse = this.applyCommand(
|
||||||
|
tx,
|
||||||
|
input.projectId,
|
||||||
|
input.source,
|
||||||
|
input.command
|
||||||
|
);
|
||||||
|
const revision = appendProjectRevision(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
expectedRevision: input.expectedRevision,
|
||||||
|
source: input.source,
|
||||||
|
description: input.description,
|
||||||
|
actorId: input.actorId,
|
||||||
|
forward: input.command,
|
||||||
|
inverse,
|
||||||
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
return { revision, inverse };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCommand(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
|
||||||
|
command: CircuitDeviceRowStructureProjectCommand
|
||||||
|
): CircuitDeviceRowStructureProjectCommand {
|
||||||
|
if (command.type === circuitDeviceRowInsertCommandType) {
|
||||||
|
assertCircuitDeviceRowInsertProjectCommand(command);
|
||||||
|
return this.insert(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
source,
|
||||||
|
command.payload.row
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (command.type === circuitDeviceRowDeleteCommandType) {
|
||||||
|
assertCircuitDeviceRowDeleteProjectCommand(command);
|
||||||
|
return this.delete(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
command.payload.rowId,
|
||||||
|
command.payload.expectedCircuitId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported circuit device-row structure command.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private insert(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
|
||||||
|
row: CircuitDeviceRowSnapshot
|
||||||
|
) {
|
||||||
|
if (source === "user" && row.legacyConsumerId !== null) {
|
||||||
|
throw new Error(
|
||||||
|
"User commands cannot assign legacy consumer ids."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.assertCircuitInProject(database, projectId, row.circuitId);
|
||||||
|
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
|
||||||
|
|
||||||
|
const existing = database
|
||||||
|
.select({ id: circuitDeviceRows.id })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, row.id))
|
||||||
|
.get();
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("Circuit device-row id already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
database.insert(circuitDeviceRows).values(row).run();
|
||||||
|
const circuitUpdate = database
|
||||||
|
.update(circuits)
|
||||||
|
.set({ isReserve: 0 })
|
||||||
|
.where(eq(circuits.id, row.circuitId))
|
||||||
|
.run();
|
||||||
|
if (circuitUpdate.changes !== 1) {
|
||||||
|
throw new Error("Circuit changed before device-row insertion.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return createCircuitDeviceRowDeleteProjectCommand(
|
||||||
|
row.id,
|
||||||
|
row.circuitId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private delete(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
rowId: string,
|
||||||
|
expectedCircuitId: string
|
||||||
|
) {
|
||||||
|
const row = database
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, rowId))
|
||||||
|
.get();
|
||||||
|
if (!row || row.circuitId !== expectedCircuitId) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit device row changed before command execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.assertCircuitInProject(database, projectId, row.circuitId);
|
||||||
|
|
||||||
|
const result = database
|
||||||
|
.delete(circuitDeviceRows)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitDeviceRows.id, rowId),
|
||||||
|
eq(circuitDeviceRows.circuitId, expectedCircuitId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error("Circuit device row could not be deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingRow = database
|
||||||
|
.select({ id: circuitDeviceRows.id })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, expectedCircuitId))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
const circuitUpdate = database
|
||||||
|
.update(circuits)
|
||||||
|
.set({ isReserve: remainingRow ? 0 : 1 })
|
||||||
|
.where(eq(circuits.id, expectedCircuitId))
|
||||||
|
.run();
|
||||||
|
if (circuitUpdate.changes !== 1) {
|
||||||
|
throw new Error("Circuit changed before device-row deletion.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return createCircuitDeviceRowInsertProjectCommand(
|
||||||
|
toCircuitDeviceRowSnapshot(row)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCircuitInProject(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
circuitId: string
|
||||||
|
) {
|
||||||
|
const circuit = database
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuits.circuitListId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.id, circuitId),
|
||||||
|
eq(circuitLists.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!circuit) {
|
||||||
|
throw new Error("Circuit does not belong to project.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { rooms } from "../schema/rooms.js";
|
||||||
|
|
||||||
|
export function assertCircuitDeviceRowReferencesInProject(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
row: CircuitDeviceRowSnapshot
|
||||||
|
) {
|
||||||
|
if (row.linkedProjectDeviceId !== null) {
|
||||||
|
const device = database
|
||||||
|
.select({ id: projectDevices.id })
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectDevices.id, row.linkedProjectDeviceId),
|
||||||
|
eq(projectDevices.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!device) {
|
||||||
|
throw new Error("Invalid linked project device id.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (row.roomId !== null) {
|
||||||
|
const room = database
|
||||||
|
.select({ id: rooms.id })
|
||||||
|
.from(rooms)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rooms.id, row.roomId),
|
||||||
|
eq(rooms.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!room) {
|
||||||
|
throw new Error("Invalid room id.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCircuitDeviceRowSnapshot(
|
||||||
|
row: typeof circuitDeviceRows.$inferSelect
|
||||||
|
): CircuitDeviceRowSnapshot {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
circuitId: row.circuitId,
|
||||||
|
linkedProjectDeviceId: row.linkedProjectDeviceId,
|
||||||
|
legacyConsumerId: row.legacyConsumerId,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
name: row.name,
|
||||||
|
displayName: row.displayName,
|
||||||
|
phaseType: row.phaseType,
|
||||||
|
connectionKind: row.connectionKind,
|
||||||
|
costGroup: row.costGroup,
|
||||||
|
category: row.category,
|
||||||
|
level: row.level,
|
||||||
|
roomId: row.roomId,
|
||||||
|
roomNumberSnapshot: row.roomNumberSnapshot,
|
||||||
|
roomNameSnapshot: row.roomNameSnapshot,
|
||||||
|
quantity: row.quantity,
|
||||||
|
powerPerUnit: row.powerPerUnit,
|
||||||
|
simultaneityFactor: row.simultaneityFactor,
|
||||||
|
cosPhi: row.cosPhi,
|
||||||
|
remark: row.remark,
|
||||||
|
overriddenFields: row.overriddenFields,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,9 +20,26 @@ export interface CircuitDeviceRowUpdateInput {
|
|||||||
overriddenFields?: string;
|
overriddenFields?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CircuitDeviceRowPatchInput = Partial<CircuitDeviceRowUpdateInput> & {
|
export interface CircuitDeviceRowPatchInput {
|
||||||
|
linkedProjectDeviceId?: string | null;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
};
|
name?: string;
|
||||||
|
displayName?: string;
|
||||||
|
phaseType?: string | null;
|
||||||
|
connectionKind?: string | null;
|
||||||
|
costGroup?: string | null;
|
||||||
|
category?: string | null;
|
||||||
|
level?: string | null;
|
||||||
|
roomId?: string | null;
|
||||||
|
roomNumberSnapshot?: string | null;
|
||||||
|
roomNameSnapshot?: string | null;
|
||||||
|
quantity?: number;
|
||||||
|
powerPerUnit?: number;
|
||||||
|
simultaneityFactor?: number;
|
||||||
|
cosPhi?: number | null;
|
||||||
|
remark?: string | null;
|
||||||
|
overriddenFields?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
|
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
|
||||||
circuitId: string;
|
circuitId: string;
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertCircuitUpdateProjectCommand,
|
||||||
|
createCircuitUpdateProjectCommand,
|
||||||
|
type CircuitUpdateField,
|
||||||
|
type CircuitUpdatePatch,
|
||||||
|
type CircuitUpdateValues,
|
||||||
|
} from "../../domain/models/circuit-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
CircuitProjectCommandStore,
|
||||||
|
ExecuteCircuitUpdateCommandInput,
|
||||||
|
} from "../../domain/ports/circuit-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import {
|
||||||
|
toCircuitPatchValues,
|
||||||
|
type CircuitPatchPersistenceInput,
|
||||||
|
} from "./circuit.persistence.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
type CircuitRow = typeof circuits.$inferSelect;
|
||||||
|
|
||||||
|
export class CircuitProjectCommandRepository
|
||||||
|
implements CircuitProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
executeUpdate(input: ExecuteCircuitUpdateCommandInput) {
|
||||||
|
assertCircuitUpdateProjectCommand(input.command);
|
||||||
|
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const current = tx
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, input.command.payload.circuitId))
|
||||||
|
.get();
|
||||||
|
if (!current) {
|
||||||
|
throw new Error("Invalid circuit id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const owningList = tx
|
||||||
|
.select({ id: circuitLists.id })
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitLists.id, current.circuitListId),
|
||||||
|
eq(circuitLists.projectId, input.projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!owningList) {
|
||||||
|
throw new Error("Circuit does not belong to project.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = Object.fromEntries(
|
||||||
|
input.command.payload.changes.map((change) => [
|
||||||
|
change.field,
|
||||||
|
change.value,
|
||||||
|
])
|
||||||
|
) as CircuitPatchPersistenceInput;
|
||||||
|
this.assertSectionInCircuitList(tx, current, patch.sectionId);
|
||||||
|
this.assertUniqueEquipmentIdentifier(
|
||||||
|
tx,
|
||||||
|
current,
|
||||||
|
patch.equipmentIdentifier
|
||||||
|
);
|
||||||
|
|
||||||
|
const inversePatch = Object.fromEntries(
|
||||||
|
input.command.payload.changes.map((change) => [
|
||||||
|
change.field,
|
||||||
|
getCircuitFieldValue(current, change.field),
|
||||||
|
])
|
||||||
|
) as CircuitUpdatePatch;
|
||||||
|
const inverse = createCircuitUpdateProjectCommand(
|
||||||
|
current.id,
|
||||||
|
inversePatch
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = tx
|
||||||
|
.update(circuits)
|
||||||
|
.set(toCircuitPatchValues(patch))
|
||||||
|
.where(eq(circuits.id, current.id))
|
||||||
|
.run();
|
||||||
|
if (update.changes !== 1) {
|
||||||
|
throw new Error("Circuit changed before command execution.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = appendProjectRevision(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
expectedRevision: input.expectedRevision,
|
||||||
|
source: input.source,
|
||||||
|
description: input.description,
|
||||||
|
actorId: input.actorId,
|
||||||
|
forward: input.command,
|
||||||
|
inverse,
|
||||||
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { revision, inverse };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSectionInCircuitList(
|
||||||
|
database: AppDatabase,
|
||||||
|
circuit: CircuitRow,
|
||||||
|
sectionId: string | undefined
|
||||||
|
) {
|
||||||
|
if (sectionId === undefined || sectionId === circuit.sectionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const section = database
|
||||||
|
.select({ id: circuitSections.id })
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitSections.id, sectionId),
|
||||||
|
eq(circuitSections.circuitListId, circuit.circuitListId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!section) {
|
||||||
|
throw new Error("Section does not belong to circuit list.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertUniqueEquipmentIdentifier(
|
||||||
|
database: AppDatabase,
|
||||||
|
circuit: CircuitRow,
|
||||||
|
equipmentIdentifier: string | undefined
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
equipmentIdentifier === undefined ||
|
||||||
|
equipmentIdentifier === circuit.equipmentIdentifier
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const duplicate = database
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.circuitListId, circuit.circuitListId),
|
||||||
|
eq(circuits.equipmentIdentifier, equipmentIdentifier),
|
||||||
|
ne(circuits.id, circuit.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (duplicate) {
|
||||||
|
throw new Error("Duplicate equipmentIdentifier in circuit list.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCircuitFieldValue<TField extends CircuitUpdateField>(
|
||||||
|
circuit: CircuitRow,
|
||||||
|
field: TField
|
||||||
|
): CircuitUpdateValues[TField] {
|
||||||
|
if (field === "isReserve") {
|
||||||
|
return Boolean(circuit.isReserve) as CircuitUpdateValues[TField];
|
||||||
|
}
|
||||||
|
return circuit[field] as unknown as CircuitUpdateValues[TField];
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertCircuitDeleteProjectCommand,
|
||||||
|
assertCircuitInsertProjectCommand,
|
||||||
|
circuitDeleteCommandType,
|
||||||
|
circuitInsertCommandType,
|
||||||
|
createCircuitDeleteProjectCommand,
|
||||||
|
createCircuitInsertProjectCommand,
|
||||||
|
type CircuitSnapshot,
|
||||||
|
type CircuitStructureProjectCommand,
|
||||||
|
} from "../../domain/models/circuit-structure-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
CircuitStructureProjectCommandStore,
|
||||||
|
ExecuteCircuitStructureCommandInput,
|
||||||
|
} from "../../domain/ports/circuit-structure-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import {
|
||||||
|
assertCircuitDeviceRowReferencesInProject,
|
||||||
|
toCircuitDeviceRowSnapshot,
|
||||||
|
} from "./circuit-device-row-structure.persistence.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
export class CircuitStructureProjectCommandRepository
|
||||||
|
implements CircuitStructureProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteCircuitStructureCommandInput) {
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const inverse = this.applyCommand(
|
||||||
|
tx,
|
||||||
|
input.projectId,
|
||||||
|
input.source,
|
||||||
|
input.command
|
||||||
|
);
|
||||||
|
const revision = appendProjectRevision(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
expectedRevision: input.expectedRevision,
|
||||||
|
source: input.source,
|
||||||
|
description: input.description,
|
||||||
|
actorId: input.actorId,
|
||||||
|
forward: input.command,
|
||||||
|
inverse,
|
||||||
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
return { revision, inverse };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCommand(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
source: ExecuteCircuitStructureCommandInput["source"],
|
||||||
|
command: CircuitStructureProjectCommand
|
||||||
|
): CircuitStructureProjectCommand {
|
||||||
|
if (command.type === circuitInsertCommandType) {
|
||||||
|
assertCircuitInsertProjectCommand(command);
|
||||||
|
return this.insert(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
source,
|
||||||
|
command.payload.circuit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (command.type === circuitDeleteCommandType) {
|
||||||
|
assertCircuitDeleteProjectCommand(command);
|
||||||
|
return this.delete(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
command.payload.circuitId,
|
||||||
|
command.payload.expectedCircuitListId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported circuit structure command.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private insert(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
source: ExecuteCircuitStructureCommandInput["source"],
|
||||||
|
snapshot: CircuitSnapshot
|
||||||
|
) {
|
||||||
|
this.assertCircuitLocation(database, projectId, snapshot);
|
||||||
|
|
||||||
|
const existingCircuit = database
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, snapshot.id))
|
||||||
|
.get();
|
||||||
|
if (existingCircuit) {
|
||||||
|
throw new Error("Circuit id already exists.");
|
||||||
|
}
|
||||||
|
const duplicateIdentifier = database
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.circuitListId, snapshot.circuitListId),
|
||||||
|
eq(
|
||||||
|
circuits.equipmentIdentifier,
|
||||||
|
snapshot.equipmentIdentifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (duplicateIdentifier) {
|
||||||
|
throw new Error(
|
||||||
|
"Duplicate equipmentIdentifier in circuit list."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.deviceRows.length > 0) {
|
||||||
|
const rowIds = snapshot.deviceRows.map((row) => row.id);
|
||||||
|
const existingRow = database
|
||||||
|
.select({ id: circuitDeviceRows.id })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (existingRow) {
|
||||||
|
throw new Error("Circuit device-row id already exists.");
|
||||||
|
}
|
||||||
|
for (const row of snapshot.deviceRows) {
|
||||||
|
if (source === "user" && row.legacyConsumerId !== null) {
|
||||||
|
throw new Error(
|
||||||
|
"User commands cannot assign legacy consumer ids."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertCircuitDeviceRowReferencesInProject(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
row
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
database
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: snapshot.id,
|
||||||
|
circuitListId: snapshot.circuitListId,
|
||||||
|
sectionId: snapshot.sectionId,
|
||||||
|
equipmentIdentifier: snapshot.equipmentIdentifier,
|
||||||
|
displayName: snapshot.displayName,
|
||||||
|
sortOrder: snapshot.sortOrder,
|
||||||
|
protectionType: snapshot.protectionType,
|
||||||
|
protectionRatedCurrent: snapshot.protectionRatedCurrent,
|
||||||
|
protectionCharacteristic: snapshot.protectionCharacteristic,
|
||||||
|
cableType: snapshot.cableType,
|
||||||
|
cableCrossSection: snapshot.cableCrossSection,
|
||||||
|
cableLength: snapshot.cableLength,
|
||||||
|
rcdAssignment: snapshot.rcdAssignment,
|
||||||
|
terminalDesignation: snapshot.terminalDesignation,
|
||||||
|
voltage: snapshot.voltage,
|
||||||
|
controlRequirement: snapshot.controlRequirement,
|
||||||
|
status: snapshot.status,
|
||||||
|
isReserve: snapshot.isReserve ? 1 : 0,
|
||||||
|
remark: snapshot.remark,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
if (snapshot.deviceRows.length > 0) {
|
||||||
|
database.insert(circuitDeviceRows).values(snapshot.deviceRows).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return createCircuitDeleteProjectCommand(
|
||||||
|
snapshot.id,
|
||||||
|
snapshot.circuitListId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private delete(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
circuitId: string,
|
||||||
|
expectedCircuitListId: string
|
||||||
|
) {
|
||||||
|
const circuit = database
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, circuitId))
|
||||||
|
.get();
|
||||||
|
if (!circuit || circuit.circuitListId !== expectedCircuitListId) {
|
||||||
|
throw new Error("Circuit changed before command execution.");
|
||||||
|
}
|
||||||
|
const owningList = database
|
||||||
|
.select({ id: circuitLists.id })
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitLists.id, circuit.circuitListId),
|
||||||
|
eq(circuitLists.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!owningList) {
|
||||||
|
throw new Error("Circuit does not belong to project.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = database
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, circuit.id))
|
||||||
|
.orderBy(
|
||||||
|
asc(circuitDeviceRows.sortOrder),
|
||||||
|
asc(circuitDeviceRows.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
const inverse = createCircuitInsertProjectCommand({
|
||||||
|
id: circuit.id,
|
||||||
|
circuitListId: circuit.circuitListId,
|
||||||
|
sectionId: circuit.sectionId,
|
||||||
|
equipmentIdentifier: circuit.equipmentIdentifier,
|
||||||
|
displayName: circuit.displayName,
|
||||||
|
sortOrder: circuit.sortOrder,
|
||||||
|
protectionType: circuit.protectionType,
|
||||||
|
protectionRatedCurrent: circuit.protectionRatedCurrent,
|
||||||
|
protectionCharacteristic: circuit.protectionCharacteristic,
|
||||||
|
cableType: circuit.cableType,
|
||||||
|
cableCrossSection: circuit.cableCrossSection,
|
||||||
|
cableLength: circuit.cableLength,
|
||||||
|
rcdAssignment: circuit.rcdAssignment,
|
||||||
|
terminalDesignation: circuit.terminalDesignation,
|
||||||
|
voltage: circuit.voltage,
|
||||||
|
controlRequirement: circuit.controlRequirement,
|
||||||
|
status: circuit.status,
|
||||||
|
isReserve: Boolean(circuit.isReserve),
|
||||||
|
remark: circuit.remark,
|
||||||
|
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = database
|
||||||
|
.delete(circuits)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.id, circuitId),
|
||||||
|
eq(circuits.circuitListId, expectedCircuitListId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error("Circuit could not be deleted.");
|
||||||
|
}
|
||||||
|
return inverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCircuitLocation(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
snapshot: CircuitSnapshot
|
||||||
|
) {
|
||||||
|
const list = database
|
||||||
|
.select({ id: circuitLists.id })
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitLists.id, snapshot.circuitListId),
|
||||||
|
eq(circuitLists.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!list) {
|
||||||
|
throw new Error("Circuit list does not belong to project.");
|
||||||
|
}
|
||||||
|
const section = database
|
||||||
|
.select({ id: circuitSections.id })
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitSections.id, snapshot.sectionId),
|
||||||
|
eq(
|
||||||
|
circuitSections.circuitListId,
|
||||||
|
snapshot.circuitListId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!section) {
|
||||||
|
throw new Error("Section does not belong to circuit list.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,26 @@ export interface CircuitCreatePersistenceInput {
|
|||||||
isReserve?: boolean;
|
isReserve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CircuitPatchPersistenceInput {
|
||||||
|
sectionId?: string;
|
||||||
|
equipmentIdentifier?: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
protectionType?: string | null;
|
||||||
|
protectionRatedCurrent?: number | null;
|
||||||
|
protectionCharacteristic?: string | null;
|
||||||
|
cableType?: string | null;
|
||||||
|
cableCrossSection?: string | null;
|
||||||
|
cableLength?: number | null;
|
||||||
|
voltage?: number | null;
|
||||||
|
controlRequirement?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
rcdAssignment?: string | null;
|
||||||
|
terminalDesignation?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
isReserve?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
|
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -42,3 +62,41 @@ export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenc
|
|||||||
remark: input.remark ?? null,
|
remark: input.remark ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
|
||||||
|
const values: Partial<ReturnType<typeof toCircuitCreateValues>> = {};
|
||||||
|
const has = (field: keyof CircuitPatchPersistenceInput) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(input, field);
|
||||||
|
|
||||||
|
if (input.sectionId !== undefined) values.sectionId = input.sectionId;
|
||||||
|
if (input.equipmentIdentifier !== undefined) {
|
||||||
|
values.equipmentIdentifier = input.equipmentIdentifier;
|
||||||
|
}
|
||||||
|
if (has("displayName")) values.displayName = input.displayName ?? null;
|
||||||
|
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
|
||||||
|
if (has("protectionType")) values.protectionType = input.protectionType ?? null;
|
||||||
|
if (has("protectionRatedCurrent")) {
|
||||||
|
values.protectionRatedCurrent = input.protectionRatedCurrent ?? null;
|
||||||
|
}
|
||||||
|
if (has("protectionCharacteristic")) {
|
||||||
|
values.protectionCharacteristic = input.protectionCharacteristic ?? null;
|
||||||
|
}
|
||||||
|
if (has("cableType")) values.cableType = input.cableType ?? null;
|
||||||
|
if (has("cableCrossSection")) {
|
||||||
|
values.cableCrossSection = input.cableCrossSection ?? null;
|
||||||
|
}
|
||||||
|
if (has("cableLength")) values.cableLength = input.cableLength ?? null;
|
||||||
|
if (has("rcdAssignment")) values.rcdAssignment = input.rcdAssignment ?? null;
|
||||||
|
if (has("terminalDesignation")) {
|
||||||
|
values.terminalDesignation = input.terminalDesignation ?? null;
|
||||||
|
}
|
||||||
|
if (has("voltage")) values.voltage = input.voltage ?? null;
|
||||||
|
if (has("controlRequirement")) {
|
||||||
|
values.controlRequirement = input.controlRequirement ?? null;
|
||||||
|
}
|
||||||
|
if (has("status")) values.status = input.status ?? null;
|
||||||
|
if (input.isReserve !== undefined) values.isReserve = input.isReserve ? 1 : 0;
|
||||||
|
if (has("remark")) values.remark = input.remark ?? null;
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,67 +4,19 @@ import { db } from "../client.js";
|
|||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
import {
|
import {
|
||||||
toCircuitCreateValues,
|
toCircuitCreateValues,
|
||||||
|
toCircuitPatchValues,
|
||||||
type CircuitCreatePersistenceInput,
|
type CircuitCreatePersistenceInput,
|
||||||
|
type CircuitPatchPersistenceInput,
|
||||||
} from "./circuit.persistence.js";
|
} from "./circuit.persistence.js";
|
||||||
|
|
||||||
export type { CircuitCreatePersistenceInput } from "./circuit.persistence.js";
|
export type {
|
||||||
export { toCircuitCreateValues } from "./circuit.persistence.js";
|
CircuitCreatePersistenceInput,
|
||||||
|
CircuitPatchPersistenceInput,
|
||||||
export interface CircuitPatchPersistenceInput {
|
} from "./circuit.persistence.js";
|
||||||
sectionId?: string;
|
export {
|
||||||
equipmentIdentifier?: string;
|
toCircuitCreateValues,
|
||||||
displayName?: string;
|
toCircuitPatchValues,
|
||||||
sortOrder?: number;
|
} from "./circuit.persistence.js";
|
||||||
protectionType?: string;
|
|
||||||
protectionRatedCurrent?: number;
|
|
||||||
protectionCharacteristic?: string;
|
|
||||||
cableType?: string;
|
|
||||||
cableCrossSection?: string;
|
|
||||||
cableLength?: number;
|
|
||||||
voltage?: number;
|
|
||||||
controlRequirement?: string;
|
|
||||||
remark?: string;
|
|
||||||
rcdAssignment?: string;
|
|
||||||
terminalDesignation?: string;
|
|
||||||
status?: string;
|
|
||||||
isReserve?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
|
|
||||||
const values: Partial<typeof circuits.$inferInsert> = {};
|
|
||||||
const has = (field: keyof CircuitPatchPersistenceInput) =>
|
|
||||||
Object.prototype.hasOwnProperty.call(input, field);
|
|
||||||
|
|
||||||
if (input.sectionId !== undefined) values.sectionId = input.sectionId;
|
|
||||||
if (input.equipmentIdentifier !== undefined) {
|
|
||||||
values.equipmentIdentifier = input.equipmentIdentifier;
|
|
||||||
}
|
|
||||||
if (has("displayName")) values.displayName = input.displayName ?? null;
|
|
||||||
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
|
|
||||||
if (has("protectionType")) values.protectionType = input.protectionType ?? null;
|
|
||||||
if (has("protectionRatedCurrent")) {
|
|
||||||
values.protectionRatedCurrent = input.protectionRatedCurrent ?? null;
|
|
||||||
}
|
|
||||||
if (has("protectionCharacteristic")) {
|
|
||||||
values.protectionCharacteristic = input.protectionCharacteristic ?? null;
|
|
||||||
}
|
|
||||||
if (has("cableType")) values.cableType = input.cableType ?? null;
|
|
||||||
if (has("cableCrossSection")) values.cableCrossSection = input.cableCrossSection ?? null;
|
|
||||||
if (has("cableLength")) values.cableLength = input.cableLength ?? null;
|
|
||||||
if (has("rcdAssignment")) values.rcdAssignment = input.rcdAssignment ?? null;
|
|
||||||
if (has("terminalDesignation")) {
|
|
||||||
values.terminalDesignation = input.terminalDesignation ?? null;
|
|
||||||
}
|
|
||||||
if (has("voltage")) values.voltage = input.voltage ?? null;
|
|
||||||
if (has("controlRequirement")) {
|
|
||||||
values.controlRequirement = input.controlRequirement ?? null;
|
|
||||||
}
|
|
||||||
if (has("status")) values.status = input.status ?? null;
|
|
||||||
if (input.isReserve !== undefined) values.isReserve = input.isReserve ? 1 : 0;
|
|
||||||
if (has("remark")) values.remark = input.remark ?? null;
|
|
||||||
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CircuitRepository {
|
export class CircuitRepository {
|
||||||
async findById(circuitId: string) {
|
async findById(circuitId: string) {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { and, desc, eq, max } from "drizzle-orm";
|
||||||
|
import type { ProjectRevisionSource } from "../../domain/ports/project-revision.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
|
||||||
|
|
||||||
|
export interface ApplyProjectHistoryTransitionInput {
|
||||||
|
projectId: string;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
recordedChangeSetId: string;
|
||||||
|
targetChangeSetId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyProjectHistoryTransition(
|
||||||
|
database: AppDatabase,
|
||||||
|
input: ApplyProjectHistoryTransitionInput
|
||||||
|
) {
|
||||||
|
if (input.source === "user") {
|
||||||
|
database
|
||||||
|
.delete(projectHistoryStackEntries)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectHistoryStackEntries.projectId, input.projectId),
|
||||||
|
eq(projectHistoryStackEntries.stack, "redo")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
push(
|
||||||
|
database,
|
||||||
|
input.projectId,
|
||||||
|
input.recordedChangeSetId,
|
||||||
|
"undo"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.source === "undo" || input.source === "redo") {
|
||||||
|
if (!input.targetChangeSetId) {
|
||||||
|
throw new Error(
|
||||||
|
`${input.source} requires a target change set.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sourceStack = input.source;
|
||||||
|
const targetStack = input.source === "undo" ? "redo" : "undo";
|
||||||
|
const top = database
|
||||||
|
.select({
|
||||||
|
changeSetId: projectHistoryStackEntries.changeSetId,
|
||||||
|
})
|
||||||
|
.from(projectHistoryStackEntries)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectHistoryStackEntries.projectId, input.projectId),
|
||||||
|
eq(projectHistoryStackEntries.stack, sourceStack)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(projectHistoryStackEntries.position))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (!top || top.changeSetId !== input.targetChangeSetId) {
|
||||||
|
throw new Error(
|
||||||
|
`${input.source} target is not the top ${sourceStack} command.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPosition = getNextPosition(
|
||||||
|
database,
|
||||||
|
input.projectId,
|
||||||
|
targetStack
|
||||||
|
);
|
||||||
|
const moved = database
|
||||||
|
.update(projectHistoryStackEntries)
|
||||||
|
.set({ stack: targetStack, position: nextPosition })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectHistoryStackEntries.projectId, input.projectId),
|
||||||
|
eq(
|
||||||
|
projectHistoryStackEntries.changeSetId,
|
||||||
|
input.targetChangeSetId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (moved.changes !== 1) {
|
||||||
|
throw new Error("Project history stack changed during transition.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
changeSetId: string,
|
||||||
|
stack: "undo" | "redo"
|
||||||
|
) {
|
||||||
|
database
|
||||||
|
.insert(projectHistoryStackEntries)
|
||||||
|
.values({
|
||||||
|
projectId,
|
||||||
|
changeSetId,
|
||||||
|
stack,
|
||||||
|
position: getNextPosition(database, projectId, stack),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextPosition(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
stack: "undo" | "redo"
|
||||||
|
) {
|
||||||
|
const current = database
|
||||||
|
.select({ position: max(projectHistoryStackEntries.position) })
|
||||||
|
.from(projectHistoryStackEntries)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectHistoryStackEntries.projectId, projectId),
|
||||||
|
eq(projectHistoryStackEntries.stack, stack)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
return (current?.position ?? 0) + 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { and, asc, desc, eq } from "drizzle-orm";
|
||||||
|
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
|
||||||
|
import type {
|
||||||
|
ProjectHistoryCommand,
|
||||||
|
ProjectHistoryDirection,
|
||||||
|
ProjectHistoryState,
|
||||||
|
ProjectHistoryStore,
|
||||||
|
} from "../../domain/ports/project-history.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||||
|
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
|
||||||
|
import { projectRevisions } from "../schema/project-revisions.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
|
||||||
|
export class ProjectHistoryRepository implements ProjectHistoryStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
getState(projectId: string): ProjectHistoryState | null {
|
||||||
|
const project = this.database
|
||||||
|
.select({ currentRevision: projects.currentRevision })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.get();
|
||||||
|
if (!project) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = this.database
|
||||||
|
.select()
|
||||||
|
.from(projectHistoryStackEntries)
|
||||||
|
.where(eq(projectHistoryStackEntries.projectId, projectId))
|
||||||
|
.orderBy(
|
||||||
|
asc(projectHistoryStackEntries.stack),
|
||||||
|
asc(projectHistoryStackEntries.position)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
const undoEntries = entries.filter((entry) => entry.stack === "undo");
|
||||||
|
const redoEntries = entries.filter((entry) => entry.stack === "redo");
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
currentRevision: project.currentRevision,
|
||||||
|
undoDepth: undoEntries.length,
|
||||||
|
redoDepth: redoEntries.length,
|
||||||
|
undoChangeSetId: undoEntries.at(-1)?.changeSetId ?? null,
|
||||||
|
redoChangeSetId: redoEntries.at(-1)?.changeSetId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextCommand(
|
||||||
|
projectId: string,
|
||||||
|
direction: ProjectHistoryDirection
|
||||||
|
): ProjectHistoryCommand | null {
|
||||||
|
const entry = this.database
|
||||||
|
.select({
|
||||||
|
changeSetId: projectHistoryStackEntries.changeSetId,
|
||||||
|
forwardPayloadJson: projectChangeSets.forwardPayloadJson,
|
||||||
|
inversePayloadJson: projectChangeSets.inversePayloadJson,
|
||||||
|
})
|
||||||
|
.from(projectHistoryStackEntries)
|
||||||
|
.innerJoin(
|
||||||
|
projectChangeSets,
|
||||||
|
eq(
|
||||||
|
projectChangeSets.id,
|
||||||
|
projectHistoryStackEntries.changeSetId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
projectRevisions,
|
||||||
|
eq(projectRevisions.id, projectChangeSets.projectRevisionId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectHistoryStackEntries.projectId, projectId),
|
||||||
|
eq(projectHistoryStackEntries.stack, direction),
|
||||||
|
eq(projectRevisions.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(projectHistoryStackEntries.position))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changeSetId: entry.changeSetId,
|
||||||
|
command: deserializeProjectCommand(
|
||||||
|
direction === "undo"
|
||||||
|
? entry.inversePayloadJson
|
||||||
|
: entry.forwardPayloadJson
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||||
|
import { serializeProjectCommand } from "../../domain/models/project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendProjectRevisionInput,
|
||||||
|
AppendedProjectRevision,
|
||||||
|
} from "../../domain/ports/project-revision.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||||
|
import { projectRevisions } from "../schema/project-revisions.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
|
||||||
|
export function appendProjectRevision(
|
||||||
|
database: AppDatabase,
|
||||||
|
input: AppendProjectRevisionInput
|
||||||
|
): AppendedProjectRevision {
|
||||||
|
validateInput(input);
|
||||||
|
|
||||||
|
const forwardPayloadJson = serializeProjectCommand(input.forward);
|
||||||
|
const inversePayloadJson = serializeProjectCommand(input.inverse);
|
||||||
|
const revisionId = crypto.randomUUID();
|
||||||
|
const changeSetId = crypto.randomUUID();
|
||||||
|
const revisionNumber = input.expectedRevision + 1;
|
||||||
|
const createdAtIso = new Date().toISOString();
|
||||||
|
|
||||||
|
const revisionUpdate = database
|
||||||
|
.update(projects)
|
||||||
|
.set({ currentRevision: revisionNumber })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projects.id, input.projectId),
|
||||||
|
eq(projects.currentRevision, input.expectedRevision)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (revisionUpdate.changes !== 1) {
|
||||||
|
const current = database
|
||||||
|
.select({ currentRevision: projects.currentRevision })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, input.projectId))
|
||||||
|
.get();
|
||||||
|
throw new ProjectRevisionConflictError(
|
||||||
|
input.projectId,
|
||||||
|
input.expectedRevision,
|
||||||
|
current?.currentRevision ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
database
|
||||||
|
.insert(projectRevisions)
|
||||||
|
.values({
|
||||||
|
id: revisionId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
revisionNumber,
|
||||||
|
createdAtIso,
|
||||||
|
actorId: input.actorId,
|
||||||
|
source: input.source,
|
||||||
|
description: input.description,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
database
|
||||||
|
.insert(projectChangeSets)
|
||||||
|
.values({
|
||||||
|
id: changeSetId,
|
||||||
|
projectRevisionId: revisionId,
|
||||||
|
commandType: input.forward.type,
|
||||||
|
payloadSchemaVersion: input.forward.schemaVersion,
|
||||||
|
forwardPayloadJson,
|
||||||
|
inversePayloadJson,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
revisionId,
|
||||||
|
changeSetId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
revisionNumber,
|
||||||
|
createdAtIso,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInput(input: AppendProjectRevisionInput) {
|
||||||
|
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) {
|
||||||
|
throw new Error("Expected project revision must be a non-negative integer.");
|
||||||
|
}
|
||||||
|
if (input.forward.schemaVersion !== input.inverse.schemaVersion) {
|
||||||
|
throw new Error(
|
||||||
|
"Forward and inverse project commands require the same schema version."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||||
|
import type {
|
||||||
|
AppendProjectRevisionInput,
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionStore,
|
||||||
|
} from "../../domain/ports/project-revision.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
export { ProjectRevisionConflictError };
|
||||||
|
|
||||||
|
export class ProjectRevisionRepository implements ProjectRevisionStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
getCurrentRevision(projectId: string) {
|
||||||
|
const project = this.database
|
||||||
|
.select({ currentRevision: projects.currentRevision })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.get();
|
||||||
|
return project?.currentRevision ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
append(input: AppendProjectRevisionInput): AppendedProjectRevision {
|
||||||
|
return this.database.transaction((tx) =>
|
||||||
|
appendProjectRevision(tx, input)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export class ProjectRepository {
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
|
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
|
||||||
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
|
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
|
||||||
|
currentRevision: 0,
|
||||||
};
|
};
|
||||||
await db.insert(projects).values(project);
|
await db.insert(projects).values(project);
|
||||||
return project;
|
return project;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||||
|
import { projectRevisions } from "./project-revisions.js";
|
||||||
|
|
||||||
|
export const projectChangeSets = sqliteTable(
|
||||||
|
"project_change_sets",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
projectRevisionId: text("project_revision_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projectRevisions.id, { onDelete: "cascade" }),
|
||||||
|
commandType: text("command_type").notNull(),
|
||||||
|
payloadSchemaVersion: integer("payload_schema_version").notNull(),
|
||||||
|
forwardPayloadJson: text("forward_payload_json").notNull(),
|
||||||
|
inversePayloadJson: text("inverse_payload_json").notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
unique("project_change_sets_revision_unique").on(table.projectRevisionId),
|
||||||
|
]
|
||||||
|
);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
check,
|
||||||
|
integer,
|
||||||
|
sqliteTable,
|
||||||
|
text,
|
||||||
|
unique,
|
||||||
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
import { projectChangeSets } from "./project-change-sets.js";
|
||||||
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
|
export const projectHistoryStackEntries = sqliteTable(
|
||||||
|
"project_history_stack_entries",
|
||||||
|
{
|
||||||
|
changeSetId: text("change_set_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => projectChangeSets.id, { onDelete: "cascade" }),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
stack: text("stack").notNull(),
|
||||||
|
position: integer("position").notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check(
|
||||||
|
"project_history_stack_entries_stack_check",
|
||||||
|
sql`${table.stack} in ('undo', 'redo')`
|
||||||
|
),
|
||||||
|
unique("project_history_stack_entries_position_unique").on(
|
||||||
|
table.projectId,
|
||||||
|
table.stack,
|
||||||
|
table.position
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||||
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
|
export const projectRevisions = sqliteTable(
|
||||||
|
"project_revisions",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
revisionNumber: integer("revision_number").notNull(),
|
||||||
|
createdAtIso: text("created_at_iso").notNull(),
|
||||||
|
actorId: text("actor_id"),
|
||||||
|
source: text("source").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
unique("project_revisions_project_number_unique").on(
|
||||||
|
table.projectId,
|
||||||
|
table.revisionNumber
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
@@ -5,4 +5,5 @@ export const projects = sqliteTable("projects", {
|
|||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
|
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
|
||||||
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
|
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
|
||||||
|
currentRevision: integer("current_revision").notNull().default(0),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ProjectHistoryDirection } from "../ports/project-history.store.js";
|
||||||
|
|
||||||
|
export class ProjectHistoryOperationUnavailableError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly projectId: string,
|
||||||
|
readonly direction: ProjectHistoryDirection
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
`Project ${projectId} has no ${direction} operation available.`
|
||||||
|
);
|
||||||
|
this.name = "ProjectHistoryOperationUnavailableError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export class ProjectRevisionConflictError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly projectId: string,
|
||||||
|
readonly expectedRevision: number,
|
||||||
|
readonly actualRevision: number | null
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
actualRevision === null
|
||||||
|
? `Project ${projectId} does not exist.`
|
||||||
|
: `Project ${projectId} is at revision ${actualRevision}, expected ${expectedRevision}.`
|
||||||
|
);
|
||||||
|
this.name = "ProjectRevisionConflictError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitInsertProjectCommand,
|
||||||
|
circuitInsertCommandType,
|
||||||
|
circuitStructureCommandSchemaVersion,
|
||||||
|
type CircuitSnapshot,
|
||||||
|
} from "./circuit-structure-project-command.model.js";
|
||||||
|
|
||||||
|
export const circuitDeviceRowMoveCommandType =
|
||||||
|
"circuit-device-row.move" as const;
|
||||||
|
export const circuitDeviceRowMoveWithNewCircuitCommandType =
|
||||||
|
"circuit-device-row.move-with-new-circuit" as const;
|
||||||
|
export const circuitDeviceRowMoveCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowMoveAssignment {
|
||||||
|
rowId: string;
|
||||||
|
expectedCircuitId: string;
|
||||||
|
expectedSortOrder: number;
|
||||||
|
targetCircuitId: string;
|
||||||
|
targetSortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowMoveCommandPayload {
|
||||||
|
moves: CircuitDeviceRowMoveAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowMoveProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitDeviceRowMoveCommandPayload> {
|
||||||
|
schemaVersion: typeof circuitDeviceRowMoveCommandSchemaVersion;
|
||||||
|
type: typeof circuitDeviceRowMoveCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CircuitDeviceRowMoveTargetCircuitAction =
|
||||||
|
| "create"
|
||||||
|
| "delete";
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowMoveWithNewCircuitCommandPayload {
|
||||||
|
targetCircuitAction: CircuitDeviceRowMoveTargetCircuitAction;
|
||||||
|
targetCircuit: CircuitSnapshot;
|
||||||
|
moves: CircuitDeviceRowMoveAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowMoveWithNewCircuitProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitDeviceRowMoveWithNewCircuitCommandPayload> {
|
||||||
|
schemaVersion: typeof circuitDeviceRowMoveCommandSchemaVersion;
|
||||||
|
type: typeof circuitDeviceRowMoveWithNewCircuitCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CircuitDeviceRowMoveHistoryProjectCommand =
|
||||||
|
| CircuitDeviceRowMoveProjectCommand
|
||||||
|
| CircuitDeviceRowMoveWithNewCircuitProjectCommand;
|
||||||
|
|
||||||
|
export function createCircuitDeviceRowMoveProjectCommand(
|
||||||
|
moves: CircuitDeviceRowMoveAssignment[]
|
||||||
|
): CircuitDeviceRowMoveProjectCommand {
|
||||||
|
const command: CircuitDeviceRowMoveProjectCommand = {
|
||||||
|
schemaVersion: circuitDeviceRowMoveCommandSchemaVersion,
|
||||||
|
type: circuitDeviceRowMoveCommandType,
|
||||||
|
payload: { moves },
|
||||||
|
};
|
||||||
|
assertCircuitDeviceRowMoveProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
targetCircuitAction: CircuitDeviceRowMoveTargetCircuitAction,
|
||||||
|
targetCircuit: CircuitSnapshot,
|
||||||
|
moves: CircuitDeviceRowMoveAssignment[]
|
||||||
|
): CircuitDeviceRowMoveWithNewCircuitProjectCommand {
|
||||||
|
const command: CircuitDeviceRowMoveWithNewCircuitProjectCommand = {
|
||||||
|
schemaVersion: circuitDeviceRowMoveCommandSchemaVersion,
|
||||||
|
type: circuitDeviceRowMoveWithNewCircuitCommandType,
|
||||||
|
payload: {
|
||||||
|
targetCircuitAction,
|
||||||
|
targetCircuit,
|
||||||
|
moves,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitDeviceRowMoveProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitDeviceRowMoveProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitDeviceRowMoveCommandSchemaVersion ||
|
||||||
|
command.type !== circuitDeviceRowMoveCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit device-row move command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit device-row move payload must be an object."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const moves = command.payload.moves;
|
||||||
|
if (!Array.isArray(moves) || moves.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit device-row move command requires at least one move."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowIds = new Set<string>();
|
||||||
|
for (const move of moves) {
|
||||||
|
if (!isPlainObject(move)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit device-row move command contains an invalid move."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertNonEmptyString(move.rowId, "rowId");
|
||||||
|
assertNonEmptyString(
|
||||||
|
move.expectedCircuitId,
|
||||||
|
"expectedCircuitId"
|
||||||
|
);
|
||||||
|
assertFiniteNumber(
|
||||||
|
move.expectedSortOrder,
|
||||||
|
"expectedSortOrder"
|
||||||
|
);
|
||||||
|
assertNonEmptyString(move.targetCircuitId, "targetCircuitId");
|
||||||
|
assertFiniteNumber(move.targetSortOrder, "targetSortOrder");
|
||||||
|
if (rowIds.has(move.rowId)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit device-row move command contains duplicate row ids."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
move.expectedCircuitId === move.targetCircuitId &&
|
||||||
|
move.expectedSortOrder === move.targetSortOrder
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit device-row move command contains a no-op move."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rowIds.add(move.rowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitDeviceRowMoveWithNewCircuitProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitDeviceRowMoveCommandSchemaVersion ||
|
||||||
|
command.type !== circuitDeviceRowMoveWithNewCircuitCommandType
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Unsupported circuit device-row move-with-new-circuit command."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit device-row move-with-new-circuit payload must be an object."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { targetCircuitAction, targetCircuit, moves } =
|
||||||
|
command.payload;
|
||||||
|
if (
|
||||||
|
targetCircuitAction !== "create" &&
|
||||||
|
targetCircuitAction !== "delete"
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Target circuit action must be create or delete."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCircuitInsertProjectCommand({
|
||||||
|
schemaVersion: circuitStructureCommandSchemaVersion,
|
||||||
|
type: circuitInsertCommandType,
|
||||||
|
payload: { circuit: targetCircuit },
|
||||||
|
});
|
||||||
|
const validatedTargetCircuit = targetCircuit as CircuitSnapshot;
|
||||||
|
if (
|
||||||
|
validatedTargetCircuit.isReserve !== true ||
|
||||||
|
validatedTargetCircuit.deviceRows.length !== 0
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Move target circuit snapshot must describe an empty reserve circuit."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCircuitDeviceRowMoveProjectCommand({
|
||||||
|
schemaVersion: circuitDeviceRowMoveCommandSchemaVersion,
|
||||||
|
type: circuitDeviceRowMoveCommandType,
|
||||||
|
payload: { moves },
|
||||||
|
});
|
||||||
|
const validatedMoves = moves as CircuitDeviceRowMoveAssignment[];
|
||||||
|
for (const move of validatedMoves) {
|
||||||
|
if (
|
||||||
|
targetCircuitAction === "create" &&
|
||||||
|
(move.expectedCircuitId === validatedTargetCircuit.id ||
|
||||||
|
move.targetCircuitId !== validatedTargetCircuit.id)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Create-target moves must move rows into the new circuit."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
targetCircuitAction === "delete" &&
|
||||||
|
(move.expectedCircuitId !== validatedTargetCircuit.id ||
|
||||||
|
move.targetCircuitId === validatedTargetCircuit.id)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Delete-target moves must move rows out of the created circuit."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmptyString(
|
||||||
|
value: unknown,
|
||||||
|
field: string
|
||||||
|
): asserts value is string {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${field} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFiniteNumber(
|
||||||
|
value: unknown,
|
||||||
|
field: string
|
||||||
|
): asserts value is number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new Error(`${field} must be a finite number.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const circuitDeviceRowUpdateCommandType =
|
||||||
|
"circuit-device-row.update" as const;
|
||||||
|
export const circuitDeviceRowUpdateCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowUpdateValues {
|
||||||
|
linkedProjectDeviceId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
phaseType: string | null;
|
||||||
|
connectionKind: string | null;
|
||||||
|
costGroup: string | null;
|
||||||
|
category: string | null;
|
||||||
|
level: string | null;
|
||||||
|
roomId: string | null;
|
||||||
|
roomNumberSnapshot: string | null;
|
||||||
|
roomNameSnapshot: string | null;
|
||||||
|
quantity: number;
|
||||||
|
powerPerUnit: number;
|
||||||
|
simultaneityFactor: number;
|
||||||
|
cosPhi: number | null;
|
||||||
|
remark: string | null;
|
||||||
|
overriddenFields: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CircuitDeviceRowUpdateField = keyof CircuitDeviceRowUpdateValues;
|
||||||
|
export type CircuitDeviceRowUpdatePatch =
|
||||||
|
Partial<CircuitDeviceRowUpdateValues>;
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowUpdateFieldChange<
|
||||||
|
TField extends CircuitDeviceRowUpdateField = CircuitDeviceRowUpdateField,
|
||||||
|
> {
|
||||||
|
field: TField;
|
||||||
|
value: CircuitDeviceRowUpdateValues[TField];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowUpdateCommandPayload {
|
||||||
|
rowId: string;
|
||||||
|
changes: CircuitDeviceRowUpdateFieldChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowUpdateProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitDeviceRowUpdateCommandPayload> {
|
||||||
|
schemaVersion: typeof circuitDeviceRowUpdateCommandSchemaVersion;
|
||||||
|
type: typeof circuitDeviceRowUpdateCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitDeviceRowUpdateProjectCommand(
|
||||||
|
rowId: string,
|
||||||
|
patch: CircuitDeviceRowUpdatePatch
|
||||||
|
): CircuitDeviceRowUpdateProjectCommand {
|
||||||
|
const changes = Object.entries(patch).map(([field, value]) => ({
|
||||||
|
field: field as CircuitDeviceRowUpdateField,
|
||||||
|
value,
|
||||||
|
})) as CircuitDeviceRowUpdateFieldChange[];
|
||||||
|
const command: CircuitDeviceRowUpdateProjectCommand = {
|
||||||
|
schemaVersion: circuitDeviceRowUpdateCommandSchemaVersion,
|
||||||
|
type: circuitDeviceRowUpdateCommandType,
|
||||||
|
payload: { rowId, changes },
|
||||||
|
};
|
||||||
|
assertCircuitDeviceRowUpdateProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitDeviceRowUpdateProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitDeviceRowUpdateProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitDeviceRowUpdateCommandSchemaVersion ||
|
||||||
|
command.type !== circuitDeviceRowUpdateCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit device-row update command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error("Circuit device-row update payload must be an object.");
|
||||||
|
}
|
||||||
|
const rowId = command.payload.rowId;
|
||||||
|
const changes = command.payload.changes;
|
||||||
|
if (typeof rowId !== "string" || !rowId.trim()) {
|
||||||
|
throw new Error("Circuit device-row update command requires a row id.");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(changes) || changes.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit device-row update command requires at least one change."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenFields = new Set<string>();
|
||||||
|
for (const change of changes) {
|
||||||
|
if (!isPlainObject(change) || typeof change.field !== "string") {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit device-row update command contains an invalid change."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isCircuitDeviceRowUpdateField(change.field) ||
|
||||||
|
seenFields.has(change.field)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit device-row update command contains an invalid or duplicate field."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertCircuitDeviceRowUpdateFieldValue(change.field, change.value);
|
||||||
|
seenFields.add(change.field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCircuitDeviceRowUpdateFieldValue(
|
||||||
|
field: CircuitDeviceRowUpdateField,
|
||||||
|
value: unknown
|
||||||
|
) {
|
||||||
|
if (field === "name" || field === "displayName") {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${field} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (field === "sortOrder") {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new Error("sortOrder must be a finite number.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
field === "quantity" ||
|
||||||
|
field === "powerPerUnit" ||
|
||||||
|
field === "simultaneityFactor"
|
||||||
|
) {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||||
|
throw new Error(`${field} must be a non-negative finite number.`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (field === "cosPhi") {
|
||||||
|
if (
|
||||||
|
value !== null &&
|
||||||
|
(typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
||||||
|
) {
|
||||||
|
throw new Error("cosPhi must be a positive finite number or null.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value !== null && typeof value !== "string") {
|
||||||
|
throw new Error(`${field} must be a string or null.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCircuitDeviceRowUpdateField(
|
||||||
|
value: string
|
||||||
|
): value is CircuitDeviceRowUpdateField {
|
||||||
|
return [
|
||||||
|
"linkedProjectDeviceId",
|
||||||
|
"sortOrder",
|
||||||
|
"name",
|
||||||
|
"displayName",
|
||||||
|
"phaseType",
|
||||||
|
"connectionKind",
|
||||||
|
"costGroup",
|
||||||
|
"category",
|
||||||
|
"level",
|
||||||
|
"roomId",
|
||||||
|
"roomNumberSnapshot",
|
||||||
|
"roomNameSnapshot",
|
||||||
|
"quantity",
|
||||||
|
"powerPerUnit",
|
||||||
|
"simultaneityFactor",
|
||||||
|
"cosPhi",
|
||||||
|
"remark",
|
||||||
|
"overriddenFields",
|
||||||
|
].includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const circuitDeviceRowInsertCommandType =
|
||||||
|
"circuit-device-row.insert" as const;
|
||||||
|
export const circuitDeviceRowDeleteCommandType =
|
||||||
|
"circuit-device-row.delete" as const;
|
||||||
|
export const circuitDeviceRowStructureCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowSnapshot {
|
||||||
|
id: string;
|
||||||
|
circuitId: string;
|
||||||
|
linkedProjectDeviceId: string | null;
|
||||||
|
legacyConsumerId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
phaseType: string | null;
|
||||||
|
connectionKind: string | null;
|
||||||
|
costGroup: string | null;
|
||||||
|
category: string | null;
|
||||||
|
level: string | null;
|
||||||
|
roomId: string | null;
|
||||||
|
roomNumberSnapshot: string | null;
|
||||||
|
roomNameSnapshot: string | null;
|
||||||
|
quantity: number;
|
||||||
|
powerPerUnit: number;
|
||||||
|
simultaneityFactor: number;
|
||||||
|
cosPhi: number | null;
|
||||||
|
remark: string | null;
|
||||||
|
overriddenFields: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowInsertCommandPayload {
|
||||||
|
row: CircuitDeviceRowSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowDeleteCommandPayload {
|
||||||
|
rowId: string;
|
||||||
|
expectedCircuitId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowInsertProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitDeviceRowInsertCommandPayload> {
|
||||||
|
schemaVersion: typeof circuitDeviceRowStructureCommandSchemaVersion;
|
||||||
|
type: typeof circuitDeviceRowInsertCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowDeleteProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitDeviceRowDeleteCommandPayload> {
|
||||||
|
schemaVersion: typeof circuitDeviceRowStructureCommandSchemaVersion;
|
||||||
|
type: typeof circuitDeviceRowDeleteCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CircuitDeviceRowStructureProjectCommand =
|
||||||
|
| CircuitDeviceRowInsertProjectCommand
|
||||||
|
| CircuitDeviceRowDeleteProjectCommand;
|
||||||
|
|
||||||
|
export function createCircuitDeviceRowInsertProjectCommand(
|
||||||
|
row: CircuitDeviceRowSnapshot
|
||||||
|
): CircuitDeviceRowInsertProjectCommand {
|
||||||
|
const command: CircuitDeviceRowInsertProjectCommand = {
|
||||||
|
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
|
||||||
|
type: circuitDeviceRowInsertCommandType,
|
||||||
|
payload: { row },
|
||||||
|
};
|
||||||
|
assertCircuitDeviceRowInsertProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitDeviceRowDeleteProjectCommand(
|
||||||
|
rowId: string,
|
||||||
|
expectedCircuitId: string
|
||||||
|
): CircuitDeviceRowDeleteProjectCommand {
|
||||||
|
const command: CircuitDeviceRowDeleteProjectCommand = {
|
||||||
|
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
|
||||||
|
type: circuitDeviceRowDeleteCommandType,
|
||||||
|
payload: { rowId, expectedCircuitId },
|
||||||
|
};
|
||||||
|
assertCircuitDeviceRowDeleteProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitDeviceRowInsertProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitDeviceRowInsertProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !==
|
||||||
|
circuitDeviceRowStructureCommandSchemaVersion ||
|
||||||
|
command.type !== circuitDeviceRowInsertCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit device-row insert command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error("Circuit device-row insert payload must be an object.");
|
||||||
|
}
|
||||||
|
const row = command.payload.row;
|
||||||
|
if (!isPlainObject(row)) {
|
||||||
|
throw new Error("Circuit device-row insert command requires a row.");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNonEmptyString(row.id, "row.id");
|
||||||
|
assertNonEmptyString(row.circuitId, "row.circuitId");
|
||||||
|
assertNullableString(row.linkedProjectDeviceId, "row.linkedProjectDeviceId");
|
||||||
|
assertNullableString(row.legacyConsumerId, "row.legacyConsumerId");
|
||||||
|
assertFiniteNumber(row.sortOrder, "row.sortOrder");
|
||||||
|
assertNonEmptyString(row.name, "row.name");
|
||||||
|
assertNonEmptyString(row.displayName, "row.displayName");
|
||||||
|
for (const field of [
|
||||||
|
"phaseType",
|
||||||
|
"connectionKind",
|
||||||
|
"costGroup",
|
||||||
|
"category",
|
||||||
|
"level",
|
||||||
|
"roomId",
|
||||||
|
"roomNumberSnapshot",
|
||||||
|
"roomNameSnapshot",
|
||||||
|
"remark",
|
||||||
|
"overriddenFields",
|
||||||
|
] as const) {
|
||||||
|
assertNullableString(row[field], `row.${field}`);
|
||||||
|
}
|
||||||
|
assertNonNegativeNumber(row.quantity, "row.quantity");
|
||||||
|
assertNonNegativeNumber(row.powerPerUnit, "row.powerPerUnit");
|
||||||
|
assertNonNegativeNumber(
|
||||||
|
row.simultaneityFactor,
|
||||||
|
"row.simultaneityFactor"
|
||||||
|
);
|
||||||
|
if (row.cosPhi !== null) {
|
||||||
|
assertFiniteNumber(row.cosPhi, "row.cosPhi");
|
||||||
|
if (row.cosPhi <= 0) {
|
||||||
|
throw new Error("row.cosPhi must be positive or null.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitDeviceRowDeleteProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitDeviceRowDeleteProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !==
|
||||||
|
circuitDeviceRowStructureCommandSchemaVersion ||
|
||||||
|
command.type !== circuitDeviceRowDeleteCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit device-row delete command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error("Circuit device-row delete payload must be an object.");
|
||||||
|
}
|
||||||
|
assertNonEmptyString(command.payload.rowId, "rowId");
|
||||||
|
assertNonEmptyString(
|
||||||
|
command.payload.expectedCircuitId,
|
||||||
|
"expectedCircuitId"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmptyString(value: unknown, field: string) {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${field} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNullableString(value: unknown, field: string) {
|
||||||
|
if (value !== null && typeof value !== "string") {
|
||||||
|
throw new Error(`${field} must be a string or null.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFiniteNumber(
|
||||||
|
value: unknown,
|
||||||
|
field: string
|
||||||
|
): asserts value is number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new Error(`${field} must be a finite number.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonNegativeNumber(value: unknown, field: string) {
|
||||||
|
assertFiniteNumber(value, field);
|
||||||
|
if (value < 0) {
|
||||||
|
throw new Error(`${field} must not be negative.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const circuitUpdateCommandType = "circuit.update" as const;
|
||||||
|
export const circuitUpdateCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface CircuitUpdateValues {
|
||||||
|
sectionId: string;
|
||||||
|
equipmentIdentifier: string;
|
||||||
|
displayName: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
protectionType: string | null;
|
||||||
|
protectionRatedCurrent: number | null;
|
||||||
|
protectionCharacteristic: string | null;
|
||||||
|
cableType: string | null;
|
||||||
|
cableCrossSection: string | null;
|
||||||
|
cableLength: number | null;
|
||||||
|
rcdAssignment: string | null;
|
||||||
|
terminalDesignation: string | null;
|
||||||
|
voltage: number | null;
|
||||||
|
controlRequirement: string | null;
|
||||||
|
status: string | null;
|
||||||
|
isReserve: boolean;
|
||||||
|
remark: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CircuitUpdateField = keyof CircuitUpdateValues;
|
||||||
|
export type CircuitUpdatePatch = Partial<CircuitUpdateValues>;
|
||||||
|
|
||||||
|
export interface CircuitUpdateFieldChange<
|
||||||
|
TField extends CircuitUpdateField = CircuitUpdateField,
|
||||||
|
> {
|
||||||
|
field: TField;
|
||||||
|
value: CircuitUpdateValues[TField];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitUpdateCommandPayload {
|
||||||
|
circuitId: string;
|
||||||
|
changes: CircuitUpdateFieldChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitUpdateProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitUpdateCommandPayload> {
|
||||||
|
schemaVersion: typeof circuitUpdateCommandSchemaVersion;
|
||||||
|
type: typeof circuitUpdateCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitUpdateProjectCommand(
|
||||||
|
circuitId: string,
|
||||||
|
patch: CircuitUpdatePatch
|
||||||
|
): CircuitUpdateProjectCommand {
|
||||||
|
const changes = Object.entries(patch).map(([field, value]) => ({
|
||||||
|
field: field as CircuitUpdateField,
|
||||||
|
value,
|
||||||
|
})) as CircuitUpdateFieldChange[];
|
||||||
|
const command: CircuitUpdateProjectCommand = {
|
||||||
|
schemaVersion: circuitUpdateCommandSchemaVersion,
|
||||||
|
type: circuitUpdateCommandType,
|
||||||
|
payload: { circuitId, changes },
|
||||||
|
};
|
||||||
|
assertCircuitUpdateProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitUpdateProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitUpdateProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitUpdateCommandSchemaVersion ||
|
||||||
|
command.type !== circuitUpdateCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit update command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error("Circuit update payload must be an object.");
|
||||||
|
}
|
||||||
|
const circuitId = command.payload.circuitId;
|
||||||
|
const changes = command.payload.changes;
|
||||||
|
if (typeof circuitId !== "string" || !circuitId.trim()) {
|
||||||
|
throw new Error("Circuit update command requires a circuit id.");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(changes) || changes.length === 0) {
|
||||||
|
throw new Error("Circuit update command requires at least one change.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenFields = new Set<string>();
|
||||||
|
for (const change of changes) {
|
||||||
|
if (!isPlainObject(change) || typeof change.field !== "string") {
|
||||||
|
throw new Error("Circuit update command contains an invalid change.");
|
||||||
|
}
|
||||||
|
if (!isCircuitUpdateField(change.field) || seenFields.has(change.field)) {
|
||||||
|
throw new Error("Circuit update command contains an invalid or duplicate field.");
|
||||||
|
}
|
||||||
|
assertCircuitUpdateFieldValue(change.field, change.value);
|
||||||
|
seenFields.add(change.field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCircuitUpdateFieldValue(
|
||||||
|
field: CircuitUpdateField,
|
||||||
|
value: unknown
|
||||||
|
) {
|
||||||
|
if (field === "sectionId" || field === "equipmentIdentifier") {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${field} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (field === "sortOrder") {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new Error("sortOrder must be a finite number.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (field === "isReserve") {
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
throw new Error("isReserve must be a boolean.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
field === "protectionRatedCurrent" ||
|
||||||
|
field === "cableLength" ||
|
||||||
|
field === "voltage"
|
||||||
|
) {
|
||||||
|
if (value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new Error(`${field} must be a finite number or null.`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(field === "voltage" && value <= 0) ||
|
||||||
|
(field !== "voltage" && value < 0)
|
||||||
|
) {
|
||||||
|
throw new Error(`${field} is outside its allowed range.`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value !== null && typeof value !== "string") {
|
||||||
|
throw new Error(`${field} must be a string or null.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCircuitUpdateField(value: string): value is CircuitUpdateField {
|
||||||
|
return [
|
||||||
|
"sectionId",
|
||||||
|
"equipmentIdentifier",
|
||||||
|
"displayName",
|
||||||
|
"sortOrder",
|
||||||
|
"protectionType",
|
||||||
|
"protectionRatedCurrent",
|
||||||
|
"protectionCharacteristic",
|
||||||
|
"cableType",
|
||||||
|
"cableCrossSection",
|
||||||
|
"cableLength",
|
||||||
|
"rcdAssignment",
|
||||||
|
"terminalDesignation",
|
||||||
|
"voltage",
|
||||||
|
"controlRequirement",
|
||||||
|
"status",
|
||||||
|
"isReserve",
|
||||||
|
"remark",
|
||||||
|
].includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import {
|
||||||
|
assertCircuitDeviceRowInsertProjectCommand,
|
||||||
|
circuitDeviceRowInsertCommandType,
|
||||||
|
circuitDeviceRowStructureCommandSchemaVersion,
|
||||||
|
type CircuitDeviceRowSnapshot,
|
||||||
|
} from "./circuit-device-row-structure-project-command.model.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const circuitInsertCommandType = "circuit.insert" as const;
|
||||||
|
export const circuitDeleteCommandType = "circuit.delete" as const;
|
||||||
|
export const circuitStructureCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface CircuitSnapshot {
|
||||||
|
id: string;
|
||||||
|
circuitListId: string;
|
||||||
|
sectionId: string;
|
||||||
|
equipmentIdentifier: string;
|
||||||
|
displayName: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
protectionType: string | null;
|
||||||
|
protectionRatedCurrent: number | null;
|
||||||
|
protectionCharacteristic: string | null;
|
||||||
|
cableType: string | null;
|
||||||
|
cableCrossSection: string | null;
|
||||||
|
cableLength: number | null;
|
||||||
|
rcdAssignment: string | null;
|
||||||
|
terminalDesignation: string | null;
|
||||||
|
voltage: number | null;
|
||||||
|
controlRequirement: string | null;
|
||||||
|
status: string | null;
|
||||||
|
isReserve: boolean;
|
||||||
|
remark: string | null;
|
||||||
|
deviceRows: CircuitDeviceRowSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitInsertCommandPayload {
|
||||||
|
circuit: CircuitSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeleteCommandPayload {
|
||||||
|
circuitId: string;
|
||||||
|
expectedCircuitListId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitInsertProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitInsertCommandPayload> {
|
||||||
|
schemaVersion: typeof circuitStructureCommandSchemaVersion;
|
||||||
|
type: typeof circuitInsertCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeleteProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitDeleteCommandPayload> {
|
||||||
|
schemaVersion: typeof circuitStructureCommandSchemaVersion;
|
||||||
|
type: typeof circuitDeleteCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CircuitStructureProjectCommand =
|
||||||
|
| CircuitInsertProjectCommand
|
||||||
|
| CircuitDeleteProjectCommand;
|
||||||
|
|
||||||
|
export function createCircuitInsertProjectCommand(
|
||||||
|
circuit: CircuitSnapshot
|
||||||
|
): CircuitInsertProjectCommand {
|
||||||
|
const command: CircuitInsertProjectCommand = {
|
||||||
|
schemaVersion: circuitStructureCommandSchemaVersion,
|
||||||
|
type: circuitInsertCommandType,
|
||||||
|
payload: { circuit },
|
||||||
|
};
|
||||||
|
assertCircuitInsertProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitDeleteProjectCommand(
|
||||||
|
circuitId: string,
|
||||||
|
expectedCircuitListId: string
|
||||||
|
): CircuitDeleteProjectCommand {
|
||||||
|
const command: CircuitDeleteProjectCommand = {
|
||||||
|
schemaVersion: circuitStructureCommandSchemaVersion,
|
||||||
|
type: circuitDeleteCommandType,
|
||||||
|
payload: { circuitId, expectedCircuitListId },
|
||||||
|
};
|
||||||
|
assertCircuitDeleteProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitInsertProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitInsertProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitStructureCommandSchemaVersion ||
|
||||||
|
command.type !== circuitInsertCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit insert command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error("Circuit insert payload must be an object.");
|
||||||
|
}
|
||||||
|
const circuit = command.payload.circuit;
|
||||||
|
if (!isPlainObject(circuit)) {
|
||||||
|
throw new Error("Circuit insert command requires a circuit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNonEmptyString(circuit.id, "circuit.id");
|
||||||
|
assertNonEmptyString(circuit.circuitListId, "circuit.circuitListId");
|
||||||
|
assertNonEmptyString(circuit.sectionId, "circuit.sectionId");
|
||||||
|
assertNonEmptyString(
|
||||||
|
circuit.equipmentIdentifier,
|
||||||
|
"circuit.equipmentIdentifier"
|
||||||
|
);
|
||||||
|
assertNullableString(circuit.displayName, "circuit.displayName");
|
||||||
|
assertFiniteNumber(circuit.sortOrder, "circuit.sortOrder");
|
||||||
|
for (const field of [
|
||||||
|
"protectionType",
|
||||||
|
"protectionCharacteristic",
|
||||||
|
"cableType",
|
||||||
|
"cableCrossSection",
|
||||||
|
"rcdAssignment",
|
||||||
|
"terminalDesignation",
|
||||||
|
"controlRequirement",
|
||||||
|
"status",
|
||||||
|
"remark",
|
||||||
|
] as const) {
|
||||||
|
assertNullableString(circuit[field], `circuit.${field}`);
|
||||||
|
}
|
||||||
|
assertNullableNonNegativeNumber(
|
||||||
|
circuit.protectionRatedCurrent,
|
||||||
|
"circuit.protectionRatedCurrent"
|
||||||
|
);
|
||||||
|
assertNullableNonNegativeNumber(
|
||||||
|
circuit.cableLength,
|
||||||
|
"circuit.cableLength"
|
||||||
|
);
|
||||||
|
if (circuit.voltage !== null) {
|
||||||
|
assertFiniteNumber(circuit.voltage, "circuit.voltage");
|
||||||
|
if (circuit.voltage <= 0) {
|
||||||
|
throw new Error("circuit.voltage must be positive or null.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof circuit.isReserve !== "boolean") {
|
||||||
|
throw new Error("circuit.isReserve must be a boolean.");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(circuit.deviceRows)) {
|
||||||
|
throw new Error("circuit.deviceRows must be an array.");
|
||||||
|
}
|
||||||
|
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit reserve state must match whether device rows exist."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowIds = new Set<string>();
|
||||||
|
for (const row of circuit.deviceRows) {
|
||||||
|
assertCircuitDeviceRowInsertProjectCommand({
|
||||||
|
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
|
||||||
|
type: circuitDeviceRowInsertCommandType,
|
||||||
|
payload: { row },
|
||||||
|
});
|
||||||
|
if (row.circuitId !== circuit.id) {
|
||||||
|
throw new Error("Circuit device row belongs to a different circuit.");
|
||||||
|
}
|
||||||
|
if (rowIds.has(row.id)) {
|
||||||
|
throw new Error("Circuit insert command contains duplicate row ids.");
|
||||||
|
}
|
||||||
|
rowIds.add(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitDeleteProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitDeleteProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitStructureCommandSchemaVersion ||
|
||||||
|
command.type !== circuitDeleteCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit delete command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error("Circuit delete payload must be an object.");
|
||||||
|
}
|
||||||
|
assertNonEmptyString(command.payload.circuitId, "circuitId");
|
||||||
|
assertNonEmptyString(
|
||||||
|
command.payload.expectedCircuitListId,
|
||||||
|
"expectedCircuitListId"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmptyString(value: unknown, field: string) {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${field} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNullableString(value: unknown, field: string) {
|
||||||
|
if (value !== null && typeof value !== "string") {
|
||||||
|
throw new Error(`${field} must be a string or null.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFiniteNumber(
|
||||||
|
value: unknown,
|
||||||
|
field: string
|
||||||
|
): asserts value is number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new Error(`${field} must be a finite number.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNullableNonNegativeNumber(value: unknown, field: string) {
|
||||||
|
if (value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertFiniteNumber(value, field);
|
||||||
|
if (value < 0) {
|
||||||
|
throw new Error(`${field} must not be negative.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
export type ProjectCommandJsonValue =
|
||||||
|
| null
|
||||||
|
| boolean
|
||||||
|
| number
|
||||||
|
| string
|
||||||
|
| ProjectCommandJsonValue[]
|
||||||
|
| { [key: string]: ProjectCommandJsonValue };
|
||||||
|
|
||||||
|
export interface SerializedProjectCommand<TPayload = ProjectCommandJsonValue> {
|
||||||
|
schemaVersion: number;
|
||||||
|
type: string;
|
||||||
|
payload: TPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): string {
|
||||||
|
assertSerializedProjectCommand(command);
|
||||||
|
return JSON.stringify(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeProjectCommand(
|
||||||
|
serialized: string
|
||||||
|
): SerializedProjectCommand<ProjectCommandJsonValue> {
|
||||||
|
const parsed: unknown = JSON.parse(serialized);
|
||||||
|
assertSerializedProjectCommand(parsed);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSerializedProjectCommand(
|
||||||
|
value: unknown
|
||||||
|
): asserts value is SerializedProjectCommand {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
throw new Error("Project command must be an object.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(value.schemaVersion) ||
|
||||||
|
(value.schemaVersion as number) < 1
|
||||||
|
) {
|
||||||
|
throw new Error("Project command schema version must be a positive integer.");
|
||||||
|
}
|
||||||
|
if (typeof value.type !== "string" || !value.type.trim()) {
|
||||||
|
throw new Error("Project command type is required.");
|
||||||
|
}
|
||||||
|
assertJsonValue(value.payload, new WeakSet<object>());
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertJsonValue(value: unknown, parents: WeakSet<object>): void {
|
||||||
|
if (
|
||||||
|
value === null ||
|
||||||
|
typeof value === "string" ||
|
||||||
|
typeof value === "boolean"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new Error("Project command numbers must be finite.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value !== "object") {
|
||||||
|
throw new Error("Project command payload must contain JSON values only.");
|
||||||
|
}
|
||||||
|
if (parents.has(value)) {
|
||||||
|
throw new Error("Project command payload must not contain cycles.");
|
||||||
|
}
|
||||||
|
|
||||||
|
parents.add(value);
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const entry of value) {
|
||||||
|
assertJsonValue(entry, parents);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
throw new Error("Project command payload must contain plain objects only.");
|
||||||
|
}
|
||||||
|
for (const entry of Object.values(value)) {
|
||||||
|
assertJsonValue(entry, parents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parents.delete(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
if (value === null || typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const prototype = Object.getPrototypeOf(value);
|
||||||
|
return prototype === Object.prototype || prototype === null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { CircuitDeviceRowMoveHistoryProjectCommand } from "../models/circuit-device-row-move-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteCircuitDeviceRowMoveCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: CircuitDeviceRowMoveHistoryProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedCircuitDeviceRowMoveCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: CircuitDeviceRowMoveHistoryProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowMoveProjectCommandStore {
|
||||||
|
execute(
|
||||||
|
input: ExecuteCircuitDeviceRowMoveCommandInput
|
||||||
|
): ExecutedCircuitDeviceRowMoveCommand;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { CircuitDeviceRowUpdateProjectCommand } from "../models/circuit-device-row-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteCircuitDeviceRowUpdateCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: CircuitDeviceRowUpdateProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedCircuitDeviceRowUpdateCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: CircuitDeviceRowUpdateProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowProjectCommandStore {
|
||||||
|
executeUpdate(
|
||||||
|
input: ExecuteCircuitDeviceRowUpdateCommandInput
|
||||||
|
): ExecutedCircuitDeviceRowUpdateCommand;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { CircuitDeviceRowStructureProjectCommand } from "../models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteCircuitDeviceRowStructureCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: CircuitDeviceRowStructureProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedCircuitDeviceRowStructureCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: CircuitDeviceRowStructureProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowStructureProjectCommandStore {
|
||||||
|
execute(
|
||||||
|
input: ExecuteCircuitDeviceRowStructureCommandInput
|
||||||
|
): ExecutedCircuitDeviceRowStructureCommand;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { CircuitUpdateProjectCommand } from "../models/circuit-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteCircuitUpdateCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: CircuitUpdateProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedCircuitUpdateCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: CircuitUpdateProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitProjectCommandStore {
|
||||||
|
executeUpdate(
|
||||||
|
input: ExecuteCircuitUpdateCommandInput
|
||||||
|
): ExecutedCircuitUpdateCommand;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { CircuitStructureProjectCommand } from "../models/circuit-structure-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteCircuitStructureCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: CircuitStructureProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedCircuitStructureCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: CircuitStructureProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitStructureProjectCommandStore {
|
||||||
|
execute(
|
||||||
|
input: ExecuteCircuitStructureCommandInput
|
||||||
|
): ExecutedCircuitStructureCommand;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { AppendedProjectRevision } from "./project-revision.store.js";
|
||||||
|
import type { ProjectHistoryState } from "./project-history.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteUserProjectCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
command: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteProjectHistoryCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
actorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedProjectCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
history: ProjectHistoryState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCommandExecutor {
|
||||||
|
executeUser(
|
||||||
|
input: ExecuteUserProjectCommandInput
|
||||||
|
): ExecutedProjectCommand;
|
||||||
|
undo(
|
||||||
|
input: ExecuteProjectHistoryCommandInput
|
||||||
|
): ExecutedProjectCommand;
|
||||||
|
redo(
|
||||||
|
input: ExecuteProjectHistoryCommandInput
|
||||||
|
): ExecutedProjectCommand;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { SerializedProjectCommand } from "../models/project-command.model.js";
|
||||||
|
|
||||||
|
export type ProjectHistoryDirection = "undo" | "redo";
|
||||||
|
|
||||||
|
export interface ProjectHistoryState {
|
||||||
|
projectId: string;
|
||||||
|
currentRevision: number;
|
||||||
|
undoDepth: number;
|
||||||
|
redoDepth: number;
|
||||||
|
undoChangeSetId: string | null;
|
||||||
|
redoChangeSetId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectHistoryCommand {
|
||||||
|
changeSetId: string;
|
||||||
|
command: SerializedProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectHistoryStore {
|
||||||
|
getState(projectId: string): ProjectHistoryState | null;
|
||||||
|
getNextCommand(
|
||||||
|
projectId: string,
|
||||||
|
direction: ProjectHistoryDirection
|
||||||
|
): ProjectHistoryCommand | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { SerializedProjectCommand } from "../models/project-command.model.js";
|
||||||
|
|
||||||
|
export type ProjectRevisionSource =
|
||||||
|
| "user"
|
||||||
|
| "undo"
|
||||||
|
| "redo"
|
||||||
|
| "restore"
|
||||||
|
| "migration";
|
||||||
|
|
||||||
|
export interface AppendProjectRevisionInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
forward: SerializedProjectCommand<unknown>;
|
||||||
|
inverse: SerializedProjectCommand<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppendedProjectRevision {
|
||||||
|
revisionId: string;
|
||||||
|
changeSetId: string;
|
||||||
|
projectId: string;
|
||||||
|
revisionNumber: number;
|
||||||
|
createdAtIso: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectRevisionStore {
|
||||||
|
getCurrentRevision(projectId: string): number | null;
|
||||||
|
append(input: AppendProjectRevisionInput): AppendedProjectRevision;
|
||||||
|
}
|
||||||
@@ -17,11 +17,7 @@ import type {
|
|||||||
UpdateCircuitInput,
|
UpdateCircuitInput,
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
} from "../../shared/validation/circuit.schemas.js";
|
||||||
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
||||||
import { projectDeviceSyncFields } from "../../shared/constants/project-device-sync-fields.js";
|
import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js";
|
||||||
import {
|
|
||||||
parseOverriddenFields,
|
|
||||||
serializeOverriddenFields,
|
|
||||||
} from "./project-device-sync.service.js";
|
|
||||||
|
|
||||||
export class CircuitWriteService {
|
export class CircuitWriteService {
|
||||||
private readonly circuitRepository: CircuitRepository;
|
private readonly circuitRepository: CircuitRepository;
|
||||||
@@ -256,21 +252,10 @@ export class CircuitWriteService {
|
|||||||
throw new Error("Invalid device row id.");
|
throw new Error("Invalid device row id.");
|
||||||
}
|
}
|
||||||
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
||||||
let overriddenFields = input.overriddenFields;
|
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
|
||||||
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
|
current,
|
||||||
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
input
|
||||||
const inputValues = input as Record<string, unknown>;
|
);
|
||||||
const currentValues = current as unknown as Record<string, unknown>;
|
|
||||||
for (const field of projectDeviceSyncFields) {
|
|
||||||
if (
|
|
||||||
Object.prototype.hasOwnProperty.call(input, field) &&
|
|
||||||
(inputValues[field] ?? null) !== (currentValues[field] ?? null)
|
|
||||||
) {
|
|
||||||
overrides.add(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
overriddenFields = serializeOverriddenFields(overrides);
|
|
||||||
}
|
|
||||||
await this.deviceRowRepository.updateFields(rowId, {
|
await this.deviceRowRepository.updateFields(rowId, {
|
||||||
...input,
|
...input,
|
||||||
...(overriddenFields !== undefined ? { overriddenFields } : {}),
|
...(overriddenFields !== undefined ? { overriddenFields } : {}),
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { ProjectHistoryOperationUnavailableError } from "../errors/project-history-operation-unavailable.error.js";
|
||||||
|
import {
|
||||||
|
assertCircuitDeviceRowMoveProjectCommand,
|
||||||
|
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand,
|
||||||
|
circuitDeviceRowMoveCommandType,
|
||||||
|
circuitDeviceRowMoveWithNewCircuitCommandType,
|
||||||
|
} from "../models/circuit-device-row-move-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitDeviceRowUpdateProjectCommand,
|
||||||
|
circuitDeviceRowUpdateCommandType,
|
||||||
|
} from "../models/circuit-device-row-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitDeviceRowDeleteProjectCommand,
|
||||||
|
assertCircuitDeviceRowInsertProjectCommand,
|
||||||
|
circuitDeviceRowDeleteCommandType,
|
||||||
|
circuitDeviceRowInsertCommandType,
|
||||||
|
} from "../models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitUpdateProjectCommand,
|
||||||
|
circuitUpdateCommandType,
|
||||||
|
} from "../models/circuit-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitDeleteProjectCommand,
|
||||||
|
assertCircuitInsertProjectCommand,
|
||||||
|
circuitDeleteCommandType,
|
||||||
|
circuitInsertCommandType,
|
||||||
|
} from "../models/circuit-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertSerializedProjectCommand,
|
||||||
|
type SerializedProjectCommand,
|
||||||
|
} from "../models/project-command.model.js";
|
||||||
|
import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js";
|
||||||
|
import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js";
|
||||||
|
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
|
||||||
|
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
|
||||||
|
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
||||||
|
import type {
|
||||||
|
ExecuteProjectHistoryCommandInput,
|
||||||
|
ExecutedProjectCommand,
|
||||||
|
ExecuteUserProjectCommandInput,
|
||||||
|
ProjectCommandExecutor,
|
||||||
|
} from "../ports/project-command.executor.js";
|
||||||
|
import type {
|
||||||
|
ProjectHistoryDirection,
|
||||||
|
ProjectHistoryStore,
|
||||||
|
} from "../ports/project-history.store.js";
|
||||||
|
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
|
||||||
|
|
||||||
|
interface DispatchProjectCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: SerializedProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
|
constructor(
|
||||||
|
private readonly circuitStore: CircuitProjectCommandStore,
|
||||||
|
private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore,
|
||||||
|
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
|
||||||
|
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
|
||||||
|
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
|
||||||
|
private readonly historyStore: ProjectHistoryStore
|
||||||
|
) {}
|
||||||
|
|
||||||
|
executeUser(
|
||||||
|
input: ExecuteUserProjectCommandInput
|
||||||
|
): ExecutedProjectCommand {
|
||||||
|
assertExpectedRevision(input.expectedRevision);
|
||||||
|
assertSerializedProjectCommand(input.command);
|
||||||
|
return this.dispatch({
|
||||||
|
...input,
|
||||||
|
source: "user",
|
||||||
|
command: input.command,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(
|
||||||
|
input: ExecuteProjectHistoryCommandInput
|
||||||
|
): ExecutedProjectCommand {
|
||||||
|
return this.executeHistory(input, "undo");
|
||||||
|
}
|
||||||
|
|
||||||
|
redo(
|
||||||
|
input: ExecuteProjectHistoryCommandInput
|
||||||
|
): ExecutedProjectCommand {
|
||||||
|
return this.executeHistory(input, "redo");
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeHistory(
|
||||||
|
input: ExecuteProjectHistoryCommandInput,
|
||||||
|
direction: ProjectHistoryDirection
|
||||||
|
) {
|
||||||
|
assertExpectedRevision(input.expectedRevision);
|
||||||
|
const step = this.historyStore.getNextCommand(
|
||||||
|
input.projectId,
|
||||||
|
direction
|
||||||
|
);
|
||||||
|
if (!step) {
|
||||||
|
throw new ProjectHistoryOperationUnavailableError(
|
||||||
|
input.projectId,
|
||||||
|
direction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dispatch({
|
||||||
|
...input,
|
||||||
|
source: direction,
|
||||||
|
description: `${direction === "undo" ? "Undo" : "Redo"} ${step.command.type}`,
|
||||||
|
historyTargetChangeSetId: step.changeSetId,
|
||||||
|
command: step.command,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatch(
|
||||||
|
input: DispatchProjectCommandInput
|
||||||
|
): ExecutedProjectCommand {
|
||||||
|
const revision = this.executeTypedCommand(input);
|
||||||
|
const history = this.historyStore.getState(input.projectId);
|
||||||
|
if (!history) {
|
||||||
|
throw new Error("Project history disappeared after command execution.");
|
||||||
|
}
|
||||||
|
return { revision, history };
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeTypedCommand(input: DispatchProjectCommandInput) {
|
||||||
|
switch (input.command.type) {
|
||||||
|
case circuitUpdateCommandType: {
|
||||||
|
assertCircuitUpdateProjectCommand(input.command);
|
||||||
|
return this.circuitStore.executeUpdate({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitDeviceRowUpdateCommandType: {
|
||||||
|
assertCircuitDeviceRowUpdateProjectCommand(input.command);
|
||||||
|
return this.deviceRowStore.executeUpdate({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitDeviceRowInsertCommandType: {
|
||||||
|
assertCircuitDeviceRowInsertProjectCommand(input.command);
|
||||||
|
return this.deviceRowStructureStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitDeviceRowDeleteCommandType: {
|
||||||
|
assertCircuitDeviceRowDeleteProjectCommand(input.command);
|
||||||
|
return this.deviceRowStructureStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitDeviceRowMoveCommandType: {
|
||||||
|
assertCircuitDeviceRowMoveProjectCommand(input.command);
|
||||||
|
return this.deviceRowMoveStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitDeviceRowMoveWithNewCircuitCommandType: {
|
||||||
|
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
input.command
|
||||||
|
);
|
||||||
|
return this.deviceRowMoveStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitInsertCommandType: {
|
||||||
|
assertCircuitInsertProjectCommand(input.command);
|
||||||
|
return this.circuitStructureStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitDeleteCommandType: {
|
||||||
|
assertCircuitDeleteProjectCommand(input.command);
|
||||||
|
return this.circuitStructureStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported project command type: ${input.command.type}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertExpectedRevision(expectedRevision: number) {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(expectedRevision) ||
|
||||||
|
expectedRevision < 0
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Expected project revision must be a non-negative integer."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
projectDeviceSyncFields,
|
||||||
|
type ProjectDeviceSyncField,
|
||||||
|
} from "../../shared/constants/project-device-sync-fields.js";
|
||||||
|
|
||||||
|
export function parseOverriddenFields(
|
||||||
|
value: string | null | undefined
|
||||||
|
): ProjectDeviceSyncField[] {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(value);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return parsed.filter((field): field is ProjectDeviceSyncField =>
|
||||||
|
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeOverriddenFields(
|
||||||
|
fields: Iterable<ProjectDeviceSyncField>
|
||||||
|
): string | undefined {
|
||||||
|
const fieldSet = new Set(fields);
|
||||||
|
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
|
||||||
|
return unique.length > 0 ? JSON.stringify(unique) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveOverriddenFieldsForLocalEdit(
|
||||||
|
current: {
|
||||||
|
linkedProjectDeviceId: string | null | undefined;
|
||||||
|
overriddenFields: string | null | undefined;
|
||||||
|
} & Partial<Record<ProjectDeviceSyncField, unknown>>,
|
||||||
|
patch: Partial<Record<ProjectDeviceSyncField | "overriddenFields", unknown>>
|
||||||
|
): string | null | undefined {
|
||||||
|
if (
|
||||||
|
!current.linkedProjectDeviceId ||
|
||||||
|
Object.prototype.hasOwnProperty.call(patch, "overriddenFields")
|
||||||
|
) {
|
||||||
|
return patch.overriddenFields as string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
||||||
|
for (const field of projectDeviceSyncFields) {
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(patch, field) &&
|
||||||
|
(patch[field] ?? null) !== (current[field] ?? null)
|
||||||
|
) {
|
||||||
|
overrides.add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return serializeOverriddenFields(overrides);
|
||||||
|
}
|
||||||
@@ -5,6 +5,15 @@ import {
|
|||||||
projectDeviceSyncFields,
|
projectDeviceSyncFields,
|
||||||
type ProjectDeviceSyncField,
|
type ProjectDeviceSyncField,
|
||||||
} from "../../shared/constants/project-device-sync-fields.js";
|
} from "../../shared/constants/project-device-sync-fields.js";
|
||||||
|
import {
|
||||||
|
parseOverriddenFields,
|
||||||
|
serializeOverriddenFields,
|
||||||
|
} from "./project-device-overrides.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
parseOverriddenFields,
|
||||||
|
serializeOverriddenFields,
|
||||||
|
} from "./project-device-overrides.js";
|
||||||
|
|
||||||
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>;
|
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>;
|
||||||
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
|
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
|
||||||
@@ -25,29 +34,6 @@ export interface ProjectDeviceSyncRestoreRow {
|
|||||||
overriddenFields: ProjectDeviceSyncField[];
|
overriddenFields: ProjectDeviceSyncField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
|
|
||||||
if (!value) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(value);
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return parsed.filter((field): field is ProjectDeviceSyncField =>
|
|
||||||
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeOverriddenFields(fields: Iterable<ProjectDeviceSyncField>): string | undefined {
|
|
||||||
const fieldSet = new Set(fields);
|
|
||||||
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
|
|
||||||
return unique.length > 0 ? JSON.stringify(unique) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
|
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
|
||||||
return projectDevice[field];
|
return projectDevice[field];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,33 @@ export interface ProjectDto {
|
|||||||
name: string;
|
name: string;
|
||||||
singlePhaseVoltageV: number;
|
singlePhaseVoltageV: number;
|
||||||
threePhaseVoltageV: number;
|
threePhaseVoltageV: number;
|
||||||
|
currentRevision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectHistoryStateDto {
|
||||||
|
projectId: string;
|
||||||
|
currentRevision: number;
|
||||||
|
undoDepth: number;
|
||||||
|
redoDepth: number;
|
||||||
|
undoChangeSetId: string | null;
|
||||||
|
redoChangeSetId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCommandDto {
|
||||||
|
schemaVersion: number;
|
||||||
|
type: string;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCommandResultDto {
|
||||||
|
revision: {
|
||||||
|
revisionId: string;
|
||||||
|
changeSetId: string;
|
||||||
|
projectId: string;
|
||||||
|
revisionNumber: number;
|
||||||
|
createdAtIso: string;
|
||||||
|
};
|
||||||
|
history: ProjectHistoryStateDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DistributionBoardDto {
|
export interface DistributionBoardDto {
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import type {
|
|||||||
DistributionBoardDto,
|
DistributionBoardDto,
|
||||||
FloorDto,
|
FloorDto,
|
||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
|
ProjectCommandDto,
|
||||||
|
ProjectCommandResultDto,
|
||||||
ProjectDeviceDto,
|
ProjectDeviceDto,
|
||||||
|
ProjectHistoryStateDto,
|
||||||
ProjectDeviceDisconnectResultDto,
|
ProjectDeviceDisconnectResultDto,
|
||||||
ProjectDeviceSyncPreviewDto,
|
ProjectDeviceSyncPreviewDto,
|
||||||
ProjectDeviceSyncRestoreRowDto,
|
ProjectDeviceSyncRestoreRowDto,
|
||||||
@@ -54,6 +57,67 @@ export function getProject(projectId: string) {
|
|||||||
return request<ProjectDto>(`/api/projects/${projectId}`);
|
return request<ProjectDto>(`/api/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProjectHistory(projectId: string) {
|
||||||
|
return request<ProjectHistoryStateDto>(
|
||||||
|
`/api/projects/${projectId}/history`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeProjectCommand(
|
||||||
|
projectId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
command: ProjectCommandDto,
|
||||||
|
description?: string
|
||||||
|
) {
|
||||||
|
return request<ProjectCommandResultDto>(
|
||||||
|
`/api/projects/${projectId}/commands`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
expectedRevision,
|
||||||
|
command,
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function undoProjectCommand(
|
||||||
|
projectId: string,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return executeProjectHistoryCommand(
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
"undo"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redoProjectCommand(
|
||||||
|
projectId: string,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return executeProjectHistoryCommand(
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
"redo"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeProjectHistoryCommand(
|
||||||
|
projectId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
direction: "undo" | "redo"
|
||||||
|
) {
|
||||||
|
return request<ProjectCommandResultDto>(
|
||||||
|
`/api/projects/${projectId}/history/${direction}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ expectedRevision }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createProject(name: string) {
|
export function createProject(name: string) {
|
||||||
return request<ProjectDto>("/api/projects", {
|
return request<ProjectDto>("/api/projects", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { db } from "../../db/client.js";
|
||||||
|
import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js";
|
||||||
|
import { CircuitDeviceRowMoveProjectCommandRepository } from "../../db/repositories/circuit-device-row-move-project-command.repository.js";
|
||||||
|
import { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-project-command.repository.js";
|
||||||
|
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
|
||||||
|
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
||||||
|
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
||||||
|
|
||||||
|
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||||
|
export const circuitDeviceRowProjectCommandStore =
|
||||||
|
new CircuitDeviceRowProjectCommandRepository(db);
|
||||||
|
export const circuitDeviceRowStructureProjectCommandStore =
|
||||||
|
new CircuitDeviceRowStructureProjectCommandRepository(db);
|
||||||
|
export const circuitDeviceRowMoveProjectCommandStore =
|
||||||
|
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
||||||
|
export const circuitStructureProjectCommandStore =
|
||||||
|
new CircuitStructureProjectCommandRepository(db);
|
||||||
|
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
||||||
|
export const projectCommandService = new ProjectCommandService(
|
||||||
|
circuitProjectCommandStore,
|
||||||
|
circuitDeviceRowProjectCommandStore,
|
||||||
|
circuitDeviceRowStructureProjectCommandStore,
|
||||||
|
circuitDeviceRowMoveProjectCommandStore,
|
||||||
|
circuitStructureProjectCommandStore,
|
||||||
|
projectHistoryStore
|
||||||
|
);
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { ProjectHistoryOperationUnavailableError } from "../../domain/errors/project-history-operation-unavailable.error.js";
|
||||||
|
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||||
|
import {
|
||||||
|
executeProjectCommandSchema,
|
||||||
|
executeProjectHistoryCommandSchema,
|
||||||
|
} from "../../shared/validation/project-command.schemas.js";
|
||||||
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
|
|
||||||
|
export function executeProjectCommand(req: Request, res: Response) {
|
||||||
|
const projectId = getProjectId(req, res);
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = executeProjectCommandSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return res.json(
|
||||||
|
projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
...parsed.data,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function undoProjectCommand(req: Request, res: Response) {
|
||||||
|
return executeHistoryCommand(req, res, "undo");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redoProjectCommand(req: Request, res: Response) {
|
||||||
|
return executeHistoryCommand(req, res, "redo");
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeHistoryCommand(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
direction: "undo" | "redo"
|
||||||
|
) {
|
||||||
|
const projectId = getProjectId(req, res);
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = executeProjectHistoryCommandSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = projectCommandService[direction]({
|
||||||
|
projectId,
|
||||||
|
...parsed.data,
|
||||||
|
});
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectId(req: Request, res: Response) {
|
||||||
|
const { projectId } = req.params;
|
||||||
|
if (typeof projectId !== "string" || !projectId.trim()) {
|
||||||
|
res.status(400).json({ error: "Invalid projectId" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function respondWithProjectCommandError(
|
||||||
|
error: unknown,
|
||||||
|
res: Response
|
||||||
|
) {
|
||||||
|
if (error instanceof ProjectRevisionConflictError) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: error.message,
|
||||||
|
code: "PROJECT_REVISION_CONFLICT",
|
||||||
|
expectedRevision: error.expectedRevision,
|
||||||
|
currentRevision: error.actualRevision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error instanceof ProjectHistoryOperationUnavailableError) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: error.message,
|
||||||
|
code: "PROJECT_HISTORY_OPERATION_UNAVAILABLE",
|
||||||
|
direction: error.direction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Project command could not be executed.",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { projectHistoryStore } from "../composition/project-command-stores.js";
|
||||||
|
|
||||||
|
export function getProjectHistory(req: Request, res: Response) {
|
||||||
|
const { projectId } = req.params;
|
||||||
|
if (typeof projectId !== "string") {
|
||||||
|
return res.status(400).json({ error: "Invalid projectId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = projectHistoryStore.getState(projectId);
|
||||||
|
if (!state) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
return res.json(state);
|
||||||
|
}
|
||||||
@@ -13,12 +13,22 @@ import { listCircuitListsByProject } from "../controllers/circuit-list.controlle
|
|||||||
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
|
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
|
||||||
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
|
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
|
||||||
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
|
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
|
||||||
|
import { getProjectHistory } from "../controllers/project-history.controller.js";
|
||||||
|
import {
|
||||||
|
executeProjectCommand,
|
||||||
|
redoProjectCommand,
|
||||||
|
undoProjectCommand,
|
||||||
|
} from "../controllers/project-command.controller.js";
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
|
|
||||||
projectRouter.get("/", listProjects);
|
projectRouter.get("/", listProjects);
|
||||||
projectRouter.post("/", createProject);
|
projectRouter.post("/", createProject);
|
||||||
projectRouter.get("/:projectId", getProject);
|
projectRouter.get("/:projectId", getProject);
|
||||||
|
projectRouter.get("/:projectId/history", getProjectHistory);
|
||||||
|
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
||||||
|
projectRouter.post("/:projectId/history/undo", undoProjectCommand);
|
||||||
|
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
|
||||||
projectRouter.put("/:projectId", updateProjectSettings);
|
projectRouter.put("/:projectId", updateProjectSettings);
|
||||||
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
||||||
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const projectCommandEnvelopeSchema = z.object({
|
||||||
|
schemaVersion: z.number().int().positive(),
|
||||||
|
type: z.string().trim().min(1),
|
||||||
|
payload: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const executeProjectCommandSchema = z.object({
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
|
description: z.string().trim().min(1).max(500).optional(),
|
||||||
|
command: projectCommandEnvelopeSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const executeProjectHistoryCommandSchema = z.object({
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,709 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-project-command.repository.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import {
|
||||||
|
createCircuitDeviceRowMoveProjectCommand,
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand,
|
||||||
|
} from "../src/domain/models/circuit-device-row-move-project-command.model.js";
|
||||||
|
import type { CircuitSnapshot } from "../src/domain/models/circuit-structure-project-command.model.js";
|
||||||
|
|
||||||
|
interface TestFixture {
|
||||||
|
context: DatabaseContext;
|
||||||
|
circuitListId: string;
|
||||||
|
sectionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestDatabase(): TestFixture {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values([
|
||||||
|
{ id: "project-1", name: "Test project" },
|
||||||
|
{ id: "project-2", name: "Other project" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
const boards = new DistributionBoardRepository(context.db);
|
||||||
|
const board = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-2",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.get();
|
||||||
|
const foreignSection = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
assert.ok(foreignSection);
|
||||||
|
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-2",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 20,
|
||||||
|
isReserve: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-3",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F3",
|
||||||
|
sortOrder: 30,
|
||||||
|
isReserve: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-foreign",
|
||||||
|
circuitListId: foreignBoard.id,
|
||||||
|
sectionId: foreignSection.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Quelle 1",
|
||||||
|
displayName: "Quelle 1",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "row-retained",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
sortOrder: 20,
|
||||||
|
name: "Bleibt",
|
||||||
|
displayName: "Bleibt",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "row-2",
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Quelle 2",
|
||||||
|
displayName: "Quelle 2",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "row-target",
|
||||||
|
circuitId: "circuit-3",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Ziel",
|
||||||
|
displayName: "Ziel",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.3,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRow(context: DatabaseContext, rowId: string) {
|
||||||
|
return context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, rowId))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReserve(context: DatabaseContext, circuitId: string) {
|
||||||
|
return Boolean(
|
||||||
|
context.db
|
||||||
|
.select({ isReserve: circuits.isReserve })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, circuitId))
|
||||||
|
.get()?.isReserve
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMoveTargetSnapshot(
|
||||||
|
fixture: TestFixture,
|
||||||
|
overrides: Partial<CircuitSnapshot> = {}
|
||||||
|
): CircuitSnapshot {
|
||||||
|
return {
|
||||||
|
id: "circuit-new",
|
||||||
|
circuitListId: fixture.circuitListId,
|
||||||
|
sectionId: fixture.sectionId,
|
||||||
|
equipmentIdentifier: "-1F4",
|
||||||
|
displayName: "Neuer Stromkreis",
|
||||||
|
sortOrder: 40,
|
||||||
|
protectionType: null,
|
||||||
|
protectionRatedCurrent: null,
|
||||||
|
protectionCharacteristic: null,
|
||||||
|
cableType: null,
|
||||||
|
cableCrossSection: null,
|
||||||
|
cableLength: null,
|
||||||
|
rcdAssignment: null,
|
||||||
|
terminalDesignation: null,
|
||||||
|
voltage: null,
|
||||||
|
controlRequirement: null,
|
||||||
|
status: null,
|
||||||
|
isReserve: true,
|
||||||
|
remark: null,
|
||||||
|
deviceRows: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit device-row move project-command repository", () => {
|
||||||
|
it("moves multiple rows and supports persisted undo and redo", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitDeviceRowMoveProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const command = createCircuitDeviceRowMoveProjectCommand([
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: "circuit-3",
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "row-2",
|
||||||
|
expectedCircuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: "circuit-3",
|
||||||
|
targetSortOrder: 30,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const moved = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
getRow(fixture.context, "row-1"),
|
||||||
|
getRow(fixture.context, "row-2"),
|
||||||
|
].map((row) => [row?.circuitId, row?.sortOrder]),
|
||||||
|
[
|
||||||
|
["circuit-3", 20],
|
||||||
|
["circuit-3", 30],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-1"), false);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-2"), true);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-3"), false);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: moved.revision.changeSetId,
|
||||||
|
command: moved.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
getRow(fixture.context, "row-1"),
|
||||||
|
getRow(fixture.context, "row-2"),
|
||||||
|
].map((row) => [row?.circuitId, row?.sortOrder]),
|
||||||
|
[
|
||||||
|
["circuit-1", 10],
|
||||||
|
["circuit-2", 10],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-1"), false);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-2"), false);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: moved.revision.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
new ProjectHistoryRepository(fixture.context.db).getState(
|
||||||
|
"project-1"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: moved.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders a row inside its current circuit", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitDeviceRowMoveProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const moved = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowMoveProjectCommand([
|
||||||
|
{
|
||||||
|
rowId: "row-retained",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetCircuitId: "circuit-1",
|
||||||
|
targetSortOrder: 5,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
getRow(fixture.context, "row-retained")?.sortOrder,
|
||||||
|
5
|
||||||
|
);
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: moved.revision.changeSetId,
|
||||||
|
command: moved.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
getRow(fixture.context, "row-retained")?.sortOrder,
|
||||||
|
20
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale row positions and cross-project targets", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitDeviceRowMoveProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowMoveProjectCommand([
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 999,
|
||||||
|
targetCircuitId: "circuit-3",
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
/changed before move/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowMoveProjectCommand([
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: "circuit-foreign",
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
/does not belong to project/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
getRow(fixture.context, "row-1")?.circuitId,
|
||||||
|
getRow(fixture.context, "row-1")?.sortOrder,
|
||||||
|
],
|
||||||
|
["circuit-1", 10]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back rows and reserve states for late history failures", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
fixture.context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_move_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced move history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const store = new CircuitDeviceRowMoveProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowMoveProjectCommand([
|
||||||
|
{
|
||||||
|
rowId: "row-2",
|
||||||
|
expectedCircuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: "circuit-3",
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
/forced move history failure/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
getRow(fixture.context, "row-2")?.circuitId,
|
||||||
|
getRow(fixture.context, "row-2")?.sortOrder,
|
||||||
|
],
|
||||||
|
["circuit-2", 10]
|
||||||
|
);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-2"), false);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-3"), false);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back a move for a stale project revision", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitDeviceRowMoveProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowMoveProjectCommand([
|
||||||
|
{
|
||||||
|
rowId: "row-2",
|
||||||
|
expectedCircuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: "circuit-3",
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
/at revision 0, expected 1/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getRow(fixture.context, "row-2")?.circuitId,
|
||||||
|
"circuit-2"
|
||||||
|
);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-2"), false);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates one target circuit and supports persisted undo and redo", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitDeviceRowMoveProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const targetCircuit = createMoveTargetSnapshot(fixture);
|
||||||
|
const command =
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
"create",
|
||||||
|
targetCircuit,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: targetCircuit.id,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "row-2",
|
||||||
|
expectedCircuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: targetCircuit.id,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const moved = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
getRow(fixture.context, "row-1"),
|
||||||
|
getRow(fixture.context, "row-2"),
|
||||||
|
].map((row) => [row?.circuitId, row?.sortOrder]),
|
||||||
|
[
|
||||||
|
["circuit-new", 10],
|
||||||
|
["circuit-new", 20],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db
|
||||||
|
.select({ equipmentIdentifier: circuits.equipmentIdentifier })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-new"))
|
||||||
|
.get()?.equipmentIdentifier,
|
||||||
|
"-1F4"
|
||||||
|
);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-new"), false);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-1"), false);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-2"), true);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: moved.revision.changeSetId,
|
||||||
|
command: moved.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-new"))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
getRow(fixture.context, "row-1"),
|
||||||
|
getRow(fixture.context, "row-2"),
|
||||||
|
].map((row) => [row?.circuitId, row?.sortOrder]),
|
||||||
|
[
|
||||||
|
["circuit-1", 10],
|
||||||
|
["circuit-2", 10],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-1"), false);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-2"), false);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: moved.revision.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
getRow(fixture.context, "row-2")?.circuitId,
|
||||||
|
"circuit-new"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
new ProjectHistoryRepository(fixture.context.db).getState(
|
||||||
|
"project-1"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: moved.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects deletion when the created target changed outside history", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitDeviceRowMoveProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const targetCircuit = createMoveTargetSnapshot(fixture);
|
||||||
|
const moved = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command:
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
"create",
|
||||||
|
targetCircuit,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-2",
|
||||||
|
expectedCircuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: targetCircuit.id,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
fixture.context.db
|
||||||
|
.update(circuits)
|
||||||
|
.set({ displayName: "Außerhalb geändert" })
|
||||||
|
.where(eq(circuits.id, targetCircuit.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
moved.revision.changeSetId,
|
||||||
|
command: moved.inverse,
|
||||||
|
}),
|
||||||
|
/target circuit changed/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getRow(fixture.context, "row-2")?.circuitId,
|
||||||
|
targetCircuit.id
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
fixture.context.db
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, targetCircuit.id))
|
||||||
|
.get()
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
new ProjectHistoryRepository(fixture.context.db).getState(
|
||||||
|
"project-1"
|
||||||
|
)?.currentRevision,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back target creation and moves for late history failures", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
fixture.context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_composite_move_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced composite move history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const targetCircuit = createMoveTargetSnapshot(fixture);
|
||||||
|
const store = new CircuitDeviceRowMoveProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command:
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
"create",
|
||||||
|
targetCircuit,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-2",
|
||||||
|
expectedCircuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: targetCircuit.id,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/forced composite move history failure/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, targetCircuit.id))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getRow(fixture.context, "row-2")?.circuitId,
|
||||||
|
"circuit-2"
|
||||||
|
);
|
||||||
|
assert.equal(isReserve(fixture.context, "circuit-2"), false);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all()
|
||||||
|
.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||||
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { rooms } from "../src/db/schema/rooms.js";
|
||||||
|
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||||
|
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
|
||||||
|
import { deserializeProjectCommand } from "../src/domain/models/project-command.model.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values([
|
||||||
|
{ id: "project-1", name: "Test project" },
|
||||||
|
{ id: "project-2", name: "Other project" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const boards = new DistributionBoardRepository(context.db);
|
||||||
|
const board = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-2",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.get();
|
||||||
|
const foreignSection = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
assert.ok(foreignSection);
|
||||||
|
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-2",
|
||||||
|
circuitListId: foreignBoard.id,
|
||||||
|
sectionId: foreignSection.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(projectDevices)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "project-device-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "project-device-2",
|
||||||
|
projectId: "project-2",
|
||||||
|
name: "Fremdgerät",
|
||||||
|
displayName: "Fremdgerät",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(rooms)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "room-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
floorId: null,
|
||||||
|
roomNumber: "001",
|
||||||
|
roomName: "Büro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "room-2",
|
||||||
|
projectId: "project-2",
|
||||||
|
floorId: null,
|
||||||
|
roomNumber: "999",
|
||||||
|
roomName: "Fremdraum",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte lokal",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "row-2",
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Fremdzeile",
|
||||||
|
displayName: "Fremdzeile",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRow(context: DatabaseContext) {
|
||||||
|
const row = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get();
|
||||||
|
assert.ok(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit device-row project-command repository", () => {
|
||||||
|
it("commits the row update, tracked override, inverse and revision together", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||||
|
const executed = store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
description: "Gerätezeile bearbeiten",
|
||||||
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: 2,
|
||||||
|
roomId: "room-1",
|
||||||
|
remark: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = getRow(context);
|
||||||
|
assert.equal(row.quantity, 2);
|
||||||
|
assert.equal(row.roomId, "room-1");
|
||||||
|
assert.equal(row.remark, null);
|
||||||
|
assert.equal(row.overriddenFields, "[\"quantity\"]");
|
||||||
|
assert.equal(executed.revision.revisionNumber, 1);
|
||||||
|
assert.deepEqual(executed.inverse.payload, {
|
||||||
|
rowId: "row-1",
|
||||||
|
changes: [
|
||||||
|
{ field: "quantity", value: 1 },
|
||||||
|
{ field: "roomId", value: null },
|
||||||
|
{ field: "remark", value: null },
|
||||||
|
{ field: "overriddenFields", value: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeSet = context.db.select().from(projectChangeSets).get();
|
||||||
|
assert.ok(changeSet);
|
||||||
|
const storedForward = deserializeProjectCommand(
|
||||||
|
changeSet.forwardPayloadJson
|
||||||
|
);
|
||||||
|
assert.deepEqual(storedForward, {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit-device-row.update",
|
||||||
|
payload: {
|
||||||
|
rowId: "row-1",
|
||||||
|
changes: [
|
||||||
|
{ field: "quantity", value: 2 },
|
||||||
|
{ field: "roomId", value: "room-1" },
|
||||||
|
{ field: "remark", value: null },
|
||||||
|
{ field: "overriddenFields", value: "[\"quantity\"]" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the generated inverse and restores override metadata", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||||
|
const forward = store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: 3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const undone = store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: forward.revision.changeSetId,
|
||||||
|
command: forward.inverse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = getRow(context);
|
||||||
|
assert.equal(row.quantity, 1);
|
||||||
|
assert.equal(row.overriddenFields, null);
|
||||||
|
assert.equal(undone.revision.revisionNumber, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({ source: projectRevisions.source })
|
||||||
|
.from(projectRevisions)
|
||||||
|
.all()
|
||||||
|
.map((revision) => revision.source),
|
||||||
|
["user", "undo"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the row and override update for a stale revision", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: 2,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: 4,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error instanceof ProjectRevisionConflictError &&
|
||||||
|
error.actualRevision === 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = getRow(context);
|
||||||
|
assert.equal(row.quantity, 2);
|
||||||
|
assert.equal(row.overriddenFields, "[\"quantity\"]");
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the row update when late history persistence fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_device_row_command_change_set
|
||||||
|
BEFORE INSERT ON project_change_sets
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced device-row history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: 5,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/forced device-row history failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = getRow(context);
|
||||||
|
assert.equal(row.quantity, 1);
|
||||||
|
assert.equal(row.overriddenFields, null);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, "project-1"))
|
||||||
|
.get()?.currentRevision,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects cross-project rows, devices and rooms without history", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowUpdateProjectCommand("row-2", {
|
||||||
|
quantity: 2,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/does not belong to project/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
linkedProjectDeviceId: "project-device-2",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/Invalid linked project device/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
roomId: "room-2",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/Invalid room/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(getRow(context).linkedProjectDeviceId, "project-device-1");
|
||||||
|
assert.equal(getRow(context).roomId, null);
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { rooms } from "../src/db/schema/rooms.js";
|
||||||
|
import {
|
||||||
|
createCircuitDeviceRowDeleteProjectCommand,
|
||||||
|
createCircuitDeviceRowInsertProjectCommand,
|
||||||
|
type CircuitDeviceRowSnapshot,
|
||||||
|
} from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values([
|
||||||
|
{ id: "project-1", name: "Test project" },
|
||||||
|
{ id: "project-2", name: "Other project" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const boards = new DistributionBoardRepository(context.db);
|
||||||
|
const board = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-2",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.get();
|
||||||
|
const foreignSection = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
assert.ok(foreignSection);
|
||||||
|
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-reserve",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 20,
|
||||||
|
isReserve: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-foreign",
|
||||||
|
circuitListId: foreignBoard.id,
|
||||||
|
sectionId: foreignSection.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(projectDevices)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "device-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "device-foreign",
|
||||||
|
projectId: "project-2",
|
||||||
|
name: "Fremdgerät",
|
||||||
|
displayName: "Fremdgerät",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(rooms)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "room-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
roomNumber: "001",
|
||||||
|
roomName: "Büro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "room-foreign",
|
||||||
|
projectId: "project-2",
|
||||||
|
roomNumber: "999",
|
||||||
|
roomName: "Fremdraum",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values({
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: "device-1",
|
||||||
|
legacyConsumerId: "legacy-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte lokal",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: "fixed",
|
||||||
|
costGroup: "440",
|
||||||
|
category: "lighting",
|
||||||
|
level: "EG",
|
||||||
|
roomId: "room-1",
|
||||||
|
roomNumberSnapshot: "001",
|
||||||
|
roomNameSnapshot: "Büro",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.12,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.9,
|
||||||
|
remark: "Bestand",
|
||||||
|
overriddenFields: "[\"displayName\"]",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInsertSnapshot(
|
||||||
|
overrides: Partial<CircuitDeviceRowSnapshot> = {}
|
||||||
|
): CircuitDeviceRowSnapshot {
|
||||||
|
return {
|
||||||
|
id: "row-new",
|
||||||
|
circuitId: "circuit-reserve",
|
||||||
|
linkedProjectDeviceId: "device-1",
|
||||||
|
legacyConsumerId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Steckdose",
|
||||||
|
displayName: "Steckdose lokal",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: "socket",
|
||||||
|
costGroup: "440",
|
||||||
|
category: "socket",
|
||||||
|
level: "EG",
|
||||||
|
roomId: "room-1",
|
||||||
|
roomNumberSnapshot: "001",
|
||||||
|
roomNameSnapshot: "Büro",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
remark: null,
|
||||||
|
overriddenFields: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIsReserve(context: DatabaseContext, circuitId: string) {
|
||||||
|
return Boolean(
|
||||||
|
context.db
|
||||||
|
.select({ isReserve: circuits.isReserve })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, circuitId))
|
||||||
|
.get()?.isReserve
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit device-row structure project-command repository", () => {
|
||||||
|
it("inserts a stable row and supports persisted undo and redo", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new CircuitDeviceRowStructureProjectCommandRepository(context.db);
|
||||||
|
const command = createCircuitDeviceRowInsertProjectCommand(
|
||||||
|
createInsertSnapshot()
|
||||||
|
);
|
||||||
|
const inserted = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-new"))
|
||||||
|
.get()?.displayName,
|
||||||
|
"Steckdose lokal"
|
||||||
|
);
|
||||||
|
assert.equal(getIsReserve(context, "circuit-reserve"), false);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command: inserted.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-new"))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.equal(getIsReserve(context, "circuit-reserve"), true);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-new"))
|
||||||
|
.get()?.id,
|
||||||
|
"row-new"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
new ProjectHistoryRepository(context.db).getState("project-1"),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: inserted.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes and restores the complete persisted row snapshot", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new CircuitDeviceRowStructureProjectCommandRepository(context.db);
|
||||||
|
const before = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get();
|
||||||
|
assert.ok(before);
|
||||||
|
|
||||||
|
const deleted = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowDeleteProjectCommand(
|
||||||
|
"row-1",
|
||||||
|
"circuit-1"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(getIsReserve(context, "circuit-1"), true);
|
||||||
|
assert.deepEqual(deleted.inverse.payload, { row: before });
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: deleted.revision.changeSetId,
|
||||||
|
command: deleted.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get(),
|
||||||
|
before
|
||||||
|
);
|
||||||
|
assert.equal(getIsReserve(context, "circuit-1"), false);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back insert and delete when late history persistence fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_structure_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced structure history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const store =
|
||||||
|
new CircuitDeviceRowStructureProjectCommandRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowInsertProjectCommand(
|
||||||
|
createInsertSnapshot()
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/forced structure history failure/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-new"))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.equal(getIsReserve(context, "circuit-reserve"), true);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowDeleteProjectCommand(
|
||||||
|
"row-1",
|
||||||
|
"circuit-1"
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/forced structure history failure/
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get()
|
||||||
|
);
|
||||||
|
assert.equal(getIsReserve(context, "circuit-1"), false);
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back a structural write for a stale expected revision", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new CircuitDeviceRowStructureProjectCommandRepository(context.db);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowDeleteProjectCommand(
|
||||||
|
"row-1",
|
||||||
|
"circuit-1"
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/at revision 0, expected 1/
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get()
|
||||||
|
);
|
||||||
|
assert.equal(getIsReserve(context, "circuit-1"), false);
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects foreign ownership, references and duplicate row ids", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new CircuitDeviceRowStructureProjectCommandRepository(context.db);
|
||||||
|
for (const snapshot of [
|
||||||
|
createInsertSnapshot({ circuitId: "circuit-foreign" }),
|
||||||
|
createInsertSnapshot({
|
||||||
|
id: "row-foreign-device",
|
||||||
|
linkedProjectDeviceId: "device-foreign",
|
||||||
|
}),
|
||||||
|
createInsertSnapshot({
|
||||||
|
id: "row-foreign-room",
|
||||||
|
roomId: "room-foreign",
|
||||||
|
}),
|
||||||
|
createInsertSnapshot({ id: "row-1" }),
|
||||||
|
createInsertSnapshot({
|
||||||
|
id: "row-legacy",
|
||||||
|
legacyConsumerId: "legacy-user-value",
|
||||||
|
}),
|
||||||
|
]) {
|
||||||
|
assert.throws(() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowInsertProjectCommand(snapshot),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||||
|
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
|
||||||
|
import { deserializeProjectCommand } from "../src/domain/models/project-command.model.js";
|
||||||
|
|
||||||
|
interface TestFixture {
|
||||||
|
context: DatabaseContext;
|
||||||
|
circuitListId: string;
|
||||||
|
sectionId: string;
|
||||||
|
foreignSectionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestFixture(): TestFixture {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values([
|
||||||
|
{ id: "project-1", name: "Test project" },
|
||||||
|
{ id: "project-2", name: "Other project" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const repository = new DistributionBoardRepository(context.db);
|
||||||
|
const board = repository.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
const foreignBoard = repository.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-2",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.get();
|
||||||
|
const foreignSection = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
assert.ok(foreignSection);
|
||||||
|
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Licht Bestand",
|
||||||
|
sortOrder: 10,
|
||||||
|
protectionRatedCurrent: 10,
|
||||||
|
isReserve: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-2",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
displayName: "Zweiter Stromkreis",
|
||||||
|
sortOrder: 20,
|
||||||
|
isReserve: 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
foreignSectionId: foreignSection.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCircuit(context: DatabaseContext) {
|
||||||
|
const circuit = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get();
|
||||||
|
assert.ok(circuit);
|
||||||
|
return circuit;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit project-command repository", () => {
|
||||||
|
it("commits a circuit update, inverse command and revision together", () => {
|
||||||
|
const fixture = createTestFixture();
|
||||||
|
try {
|
||||||
|
const store = new CircuitProjectCommandRepository(fixture.context.db);
|
||||||
|
const command = createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: null,
|
||||||
|
protectionRatedCurrent: 16,
|
||||||
|
isReserve: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const executed = store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
description: "Stromkreis bearbeiten",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
|
||||||
|
const circuit = getCircuit(fixture.context);
|
||||||
|
assert.equal(circuit.displayName, null);
|
||||||
|
assert.equal(circuit.protectionRatedCurrent, 16);
|
||||||
|
assert.equal(circuit.isReserve, 1);
|
||||||
|
assert.equal(executed.revision.revisionNumber, 1);
|
||||||
|
assert.deepEqual(executed.inverse.payload, {
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
changes: [
|
||||||
|
{ field: "displayName", value: "Licht Bestand" },
|
||||||
|
{ field: "protectionRatedCurrent", value: 10 },
|
||||||
|
{ field: "isReserve", value: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeSet = fixture.context.db
|
||||||
|
.select()
|
||||||
|
.from(projectChangeSets)
|
||||||
|
.get();
|
||||||
|
assert.ok(changeSet);
|
||||||
|
assert.deepEqual(deserializeProjectCommand(changeSet.forwardPayloadJson), command);
|
||||||
|
assert.deepEqual(
|
||||||
|
deserializeProjectCommand(changeSet.inversePayloadJson),
|
||||||
|
executed.inverse
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, "project-1"))
|
||||||
|
.get()?.currentRevision,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can apply the generated inverse as a new auditable revision", () => {
|
||||||
|
const fixture = createTestFixture();
|
||||||
|
try {
|
||||||
|
const store = new CircuitProjectCommandRepository(fixture.context.db);
|
||||||
|
const forward = store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: "Licht Neu",
|
||||||
|
protectionRatedCurrent: 16,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const undone = store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: forward.revision.changeSetId,
|
||||||
|
description: "Stromkreisänderung rückgängig machen",
|
||||||
|
command: forward.inverse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const circuit = getCircuit(fixture.context);
|
||||||
|
assert.equal(circuit.displayName, "Licht Bestand");
|
||||||
|
assert.equal(circuit.protectionRatedCurrent, 10);
|
||||||
|
assert.equal(undone.revision.revisionNumber, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
fixture.context.db
|
||||||
|
.select({ source: projectRevisions.source })
|
||||||
|
.from(projectRevisions)
|
||||||
|
.all()
|
||||||
|
.map((revision) => revision.source),
|
||||||
|
["user", "undo"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the circuit update when the expected revision is stale", () => {
|
||||||
|
const fixture = createTestFixture();
|
||||||
|
try {
|
||||||
|
const store = new CircuitProjectCommandRepository(fixture.context.db);
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: "Erste Änderung",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: "Veraltete Änderung",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error instanceof ProjectRevisionConflictError &&
|
||||||
|
error.actualRevision === 1
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(getCircuit(fixture.context).displayName, "Erste Änderung");
|
||||||
|
assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 1);
|
||||||
|
assert.equal(fixture.context.db.select().from(projectChangeSets).all().length, 1);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the circuit update when late history persistence fails", () => {
|
||||||
|
const fixture = createTestFixture();
|
||||||
|
try {
|
||||||
|
fixture.context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_circuit_command_change_set
|
||||||
|
BEFORE INSERT ON project_change_sets
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced command history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const store = new CircuitProjectCommandRepository(fixture.context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: "Darf nicht bleiben",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/forced command history failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(getCircuit(fixture.context).displayName, "Licht Bestand");
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, "project-1"))
|
||||||
|
.get()?.currentRevision,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
assert.equal(fixture.context.db.select().from(projectChangeSets).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects foreign sections and duplicate equipment identifiers before history", () => {
|
||||||
|
const fixture = createTestFixture();
|
||||||
|
try {
|
||||||
|
const store = new CircuitProjectCommandRepository(fixture.context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
sectionId: fixture.foreignSectionId,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/Section does not belong/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/Duplicate equipmentIdentifier/
|
||||||
|
);
|
||||||
|
|
||||||
|
const circuit = getCircuit(fixture.context);
|
||||||
|
assert.equal(circuit.sectionId, fixture.sectionId);
|
||||||
|
assert.equal(circuit.equipmentIdentifier, "-1F1");
|
||||||
|
assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { rooms } from "../src/db/schema/rooms.js";
|
||||||
|
import type { CircuitDeviceRowSnapshot } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
createCircuitDeleteProjectCommand,
|
||||||
|
createCircuitInsertProjectCommand,
|
||||||
|
type CircuitSnapshot,
|
||||||
|
} from "../src/domain/models/circuit-structure-project-command.model.js";
|
||||||
|
|
||||||
|
interface TestFixture {
|
||||||
|
context: DatabaseContext;
|
||||||
|
circuitListId: string;
|
||||||
|
sectionId: string;
|
||||||
|
foreignCircuitListId: string;
|
||||||
|
foreignSectionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestDatabase(): TestFixture {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values([
|
||||||
|
{ id: "project-1", name: "Test project" },
|
||||||
|
{ id: "project-2", name: "Other project" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const boards = new DistributionBoardRepository(context.db);
|
||||||
|
const board = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-2",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.get();
|
||||||
|
const foreignSection = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
assert.ok(foreignSection);
|
||||||
|
|
||||||
|
context.db
|
||||||
|
.insert(projectDevices)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "device-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "device-foreign",
|
||||||
|
projectId: "project-2",
|
||||||
|
name: "Fremdgerät",
|
||||||
|
displayName: "Fremdgerät",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(rooms)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "room-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
roomNumber: "001",
|
||||||
|
roomName: "Büro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "room-foreign",
|
||||||
|
projectId: "project-2",
|
||||||
|
roomNumber: "999",
|
||||||
|
roomName: "Fremdraum",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-existing",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Bestand",
|
||||||
|
sortOrder: 10,
|
||||||
|
protectionType: "LS",
|
||||||
|
protectionRatedCurrent: 16,
|
||||||
|
protectionCharacteristic: "B",
|
||||||
|
cableType: "NYM-J",
|
||||||
|
cableCrossSection: "3x1,5",
|
||||||
|
cableLength: 18.5,
|
||||||
|
rcdAssignment: "FI-1",
|
||||||
|
terminalDesignation: "X1",
|
||||||
|
voltage: 230,
|
||||||
|
controlRequirement: "DALI",
|
||||||
|
status: "planned",
|
||||||
|
isReserve: 0,
|
||||||
|
remark: "Vollständig",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "row-existing-1",
|
||||||
|
circuitId: "circuit-existing",
|
||||||
|
linkedProjectDeviceId: "device-1",
|
||||||
|
legacyConsumerId: "legacy-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte A",
|
||||||
|
displayName: "Leuchte A lokal",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
category: "lighting",
|
||||||
|
roomId: "room-1",
|
||||||
|
roomNumberSnapshot: "001",
|
||||||
|
roomNameSnapshot: "Büro",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.9,
|
||||||
|
overriddenFields: "[\"displayName\"]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "row-existing-2",
|
||||||
|
circuitId: "circuit-existing",
|
||||||
|
sortOrder: 20,
|
||||||
|
name: "Leuchte B",
|
||||||
|
displayName: "Leuchte B",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
foreignCircuitListId: foreignBoard.id,
|
||||||
|
foreignSectionId: foreignSection.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRow(
|
||||||
|
circuitId: string,
|
||||||
|
id: string,
|
||||||
|
sortOrder: number,
|
||||||
|
overrides: Partial<CircuitDeviceRowSnapshot> = {}
|
||||||
|
): CircuitDeviceRowSnapshot {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
circuitId,
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
legacyConsumerId: null,
|
||||||
|
sortOrder,
|
||||||
|
name: `Gerät ${id}`,
|
||||||
|
displayName: `Gerät ${id}`,
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
costGroup: null,
|
||||||
|
category: null,
|
||||||
|
level: null,
|
||||||
|
roomId: null,
|
||||||
|
roomNumberSnapshot: null,
|
||||||
|
roomNameSnapshot: null,
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: null,
|
||||||
|
remark: null,
|
||||||
|
overriddenFields: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCircuitSnapshot(
|
||||||
|
fixture: TestFixture,
|
||||||
|
overrides: Partial<CircuitSnapshot> = {}
|
||||||
|
): CircuitSnapshot {
|
||||||
|
const id = overrides.id ?? "circuit-new";
|
||||||
|
const deviceRows =
|
||||||
|
overrides.deviceRows ??
|
||||||
|
[
|
||||||
|
createRow(id, "row-new-1", 10, {
|
||||||
|
linkedProjectDeviceId: "device-1",
|
||||||
|
roomId: "room-1",
|
||||||
|
}),
|
||||||
|
createRow(id, "row-new-2", 20),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
circuitListId: fixture.circuitListId,
|
||||||
|
sectionId: fixture.sectionId,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
displayName: "Neuer Stromkreis",
|
||||||
|
sortOrder: 20,
|
||||||
|
protectionType: null,
|
||||||
|
protectionRatedCurrent: null,
|
||||||
|
protectionCharacteristic: null,
|
||||||
|
cableType: null,
|
||||||
|
cableCrossSection: null,
|
||||||
|
cableLength: null,
|
||||||
|
rcdAssignment: null,
|
||||||
|
terminalDesignation: null,
|
||||||
|
voltage: 230,
|
||||||
|
controlRequirement: null,
|
||||||
|
status: null,
|
||||||
|
isReserve: deviceRows.length === 0,
|
||||||
|
remark: null,
|
||||||
|
deviceRows,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCircuit(
|
||||||
|
context: DatabaseContext,
|
||||||
|
circuitId: string
|
||||||
|
) {
|
||||||
|
return context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, circuitId))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRows(context: DatabaseContext, circuitId: string) {
|
||||||
|
return context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, circuitId))
|
||||||
|
.orderBy(
|
||||||
|
asc(circuitDeviceRows.sortOrder),
|
||||||
|
asc(circuitDeviceRows.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit structure project-command repository", () => {
|
||||||
|
it("inserts a multi-device circuit and supports persisted undo and redo", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitStructureProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const command = createCircuitInsertProjectCommand(
|
||||||
|
createCircuitSnapshot(fixture)
|
||||||
|
);
|
||||||
|
const inserted = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.equal(getCircuit(fixture.context, "circuit-new")?.isReserve, 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
getRows(fixture.context, "circuit-new").map((row) => row.id),
|
||||||
|
["row-new-1", "row-new-2"]
|
||||||
|
);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command: inserted.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(getCircuit(fixture.context, "circuit-new"), undefined);
|
||||||
|
assert.equal(getRows(fixture.context, "circuit-new").length, 0);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
getRows(fixture.context, "circuit-new").map((row) => row.id),
|
||||||
|
["row-new-1", "row-new-2"]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
new ProjectHistoryRepository(fixture.context.db).getState(
|
||||||
|
"project-1"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: inserted.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts and removes an empty reserve circuit", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitStructureProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const inserted = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitInsertProjectCommand(
|
||||||
|
createCircuitSnapshot(fixture, {
|
||||||
|
id: "circuit-reserve",
|
||||||
|
equipmentIdentifier: "-1F3",
|
||||||
|
deviceRows: [],
|
||||||
|
isReserve: true,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
getCircuit(fixture.context, "circuit-reserve")?.isReserve,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command: inserted.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
getCircuit(fixture.context, "circuit-reserve"),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes and restores a complete multi-device circuit block", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const beforeCircuit = getCircuit(
|
||||||
|
fixture.context,
|
||||||
|
"circuit-existing"
|
||||||
|
);
|
||||||
|
const beforeRows = getRows(fixture.context, "circuit-existing");
|
||||||
|
assert.ok(beforeCircuit);
|
||||||
|
const store = new CircuitStructureProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const deleted = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeleteProjectCommand(
|
||||||
|
"circuit-existing",
|
||||||
|
fixture.circuitListId
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
getCircuit(fixture.context, "circuit-existing"),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.equal(getRows(fixture.context, "circuit-existing").length, 0);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: deleted.revision.changeSetId,
|
||||||
|
command: deleted.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
getCircuit(fixture.context, "circuit-existing"),
|
||||||
|
beforeCircuit
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getRows(fixture.context, "circuit-existing"),
|
||||||
|
beforeRows
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back insert and delete for late history failures", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
fixture.context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_circuit_structure_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced circuit structure history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const store = new CircuitStructureProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitInsertProjectCommand(
|
||||||
|
createCircuitSnapshot(fixture)
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/forced circuit structure history failure/
|
||||||
|
);
|
||||||
|
assert.equal(getCircuit(fixture.context, "circuit-new"), undefined);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeleteProjectCommand(
|
||||||
|
"circuit-existing",
|
||||||
|
fixture.circuitListId
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/forced circuit structure history failure/
|
||||||
|
);
|
||||||
|
assert.ok(getCircuit(fixture.context, "circuit-existing"));
|
||||||
|
assert.equal(getRows(fixture.context, "circuit-existing").length, 2);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back deletion for a stale expected revision", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitStructureProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeleteProjectCommand(
|
||||||
|
"circuit-existing",
|
||||||
|
fixture.circuitListId
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/at revision 0, expected 1/
|
||||||
|
);
|
||||||
|
assert.ok(getCircuit(fixture.context, "circuit-existing"));
|
||||||
|
assert.equal(getRows(fixture.context, "circuit-existing").length, 2);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid ownership, identifiers, references and row ids", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitStructureProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const invalidSnapshots = [
|
||||||
|
createCircuitSnapshot(fixture, {
|
||||||
|
id: "foreign-list",
|
||||||
|
circuitListId: fixture.foreignCircuitListId,
|
||||||
|
sectionId: fixture.foreignSectionId,
|
||||||
|
}),
|
||||||
|
createCircuitSnapshot(fixture, {
|
||||||
|
id: "foreign-section",
|
||||||
|
sectionId: fixture.foreignSectionId,
|
||||||
|
}),
|
||||||
|
createCircuitSnapshot(fixture, {
|
||||||
|
id: "duplicate-bmk",
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
}),
|
||||||
|
createCircuitSnapshot(fixture, {
|
||||||
|
id: "duplicate-row",
|
||||||
|
deviceRows: [
|
||||||
|
createRow("duplicate-row", "row-existing-1", 10),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createCircuitSnapshot(fixture, {
|
||||||
|
id: "foreign-device",
|
||||||
|
deviceRows: [
|
||||||
|
createRow("foreign-device", "row-foreign-device", 10, {
|
||||||
|
linkedProjectDeviceId: "device-foreign",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createCircuitSnapshot(fixture, {
|
||||||
|
id: "foreign-room",
|
||||||
|
deviceRows: [
|
||||||
|
createRow("foreign-room", "row-foreign-room", 10, {
|
||||||
|
roomId: "room-foreign",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createCircuitSnapshot(fixture, {
|
||||||
|
id: "legacy-row",
|
||||||
|
deviceRows: [
|
||||||
|
createRow("legacy-row", "row-legacy", 10, {
|
||||||
|
legacyConsumerId: "legacy-user-value",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
for (const snapshot of invalidSnapshots) {
|
||||||
|
assert.throws(() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitInsertProjectCommand(snapshot),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
deserializeProjectCommand,
|
||||||
|
serializeProjectCommand,
|
||||||
|
type SerializedProjectCommand,
|
||||||
|
} from "../src/domain/models/project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitUpdateProjectCommand,
|
||||||
|
createCircuitUpdateProjectCommand,
|
||||||
|
} from "../src/domain/models/circuit-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitDeviceRowUpdateProjectCommand,
|
||||||
|
createCircuitDeviceRowUpdateProjectCommand,
|
||||||
|
} from "../src/domain/models/circuit-device-row-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitDeviceRowInsertProjectCommand,
|
||||||
|
createCircuitDeviceRowDeleteProjectCommand,
|
||||||
|
createCircuitDeviceRowInsertProjectCommand,
|
||||||
|
} from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitDeviceRowMoveProjectCommand,
|
||||||
|
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand,
|
||||||
|
createCircuitDeviceRowMoveProjectCommand,
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand,
|
||||||
|
} from "../src/domain/models/circuit-device-row-move-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitInsertProjectCommand,
|
||||||
|
createCircuitDeleteProjectCommand,
|
||||||
|
createCircuitInsertProjectCommand,
|
||||||
|
} from "../src/domain/models/circuit-structure-project-command.model.js";
|
||||||
|
|
||||||
|
describe("serialized project commands", () => {
|
||||||
|
it("round-trips a versioned command envelope", () => {
|
||||||
|
const command: SerializedProjectCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.update",
|
||||||
|
payload: {
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
changes: {
|
||||||
|
displayName: "Werkstatt",
|
||||||
|
protectionRatedCurrent: 16,
|
||||||
|
isReserve: false,
|
||||||
|
remark: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
deserializeProjectCommand(serializeProjectCommand(command)),
|
||||||
|
command
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects values that JSON would lose or silently change", () => {
|
||||||
|
const invalidPayloads: unknown[] = [
|
||||||
|
{ value: undefined },
|
||||||
|
{ value: Number.NaN },
|
||||||
|
{ value: Number.POSITIVE_INFINITY },
|
||||||
|
{ value: new Date() },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const payload of invalidPayloads) {
|
||||||
|
assert.throws(() =>
|
||||||
|
serializeProjectCommand({
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "test.invalid",
|
||||||
|
payload: payload as never,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects cyclic payloads before persistence", () => {
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
payload.self = payload;
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
serializeProjectCommand({
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "test.cyclic",
|
||||||
|
payload: payload as never,
|
||||||
|
}),
|
||||||
|
/must not contain cycles/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed serialized envelopes", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
deserializeProjectCommand(
|
||||||
|
JSON.stringify({ schemaVersion: 0, type: "", payload: null })
|
||||||
|
),
|
||||||
|
/schema version/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("circuit update project commands", () => {
|
||||||
|
it("creates a typed command with explicit null clearing semantics", () => {
|
||||||
|
const command = createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: null,
|
||||||
|
cableLength: 12.5,
|
||||||
|
isReserve: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => assertCircuitUpdateProjectCommand(command));
|
||||||
|
assert.deepEqual(command.payload.changes, [
|
||||||
|
{ field: "displayName", value: null },
|
||||||
|
{ field: "cableLength", value: 12.5 },
|
||||||
|
{ field: "isReserve", value: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty, duplicate and invalid field changes", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => createCircuitUpdateProjectCommand("circuit-1", {}),
|
||||||
|
/at least one change/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
assertCircuitUpdateProjectCommand({
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.update",
|
||||||
|
payload: {
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
changes: [
|
||||||
|
{ field: "displayName", value: "A" },
|
||||||
|
{ field: "displayName", value: "B" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/duplicate field/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
voltage: 0,
|
||||||
|
}),
|
||||||
|
/outside its allowed range/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("circuit device-row update project commands", () => {
|
||||||
|
it("creates typed nullable device-row changes", () => {
|
||||||
|
const command = createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
roomId: null,
|
||||||
|
quantity: 2,
|
||||||
|
cosPhi: 0.9,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertCircuitDeviceRowUpdateProjectCommand(command)
|
||||||
|
);
|
||||||
|
assert.deepEqual(command.payload.changes, [
|
||||||
|
{ field: "roomId", value: null },
|
||||||
|
{ field: "quantity", value: 2 },
|
||||||
|
{ field: "cosPhi", value: 0.9 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid device-row values", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {}),
|
||||||
|
/at least one change/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
displayName: "",
|
||||||
|
}),
|
||||||
|
/non-empty string/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: -1,
|
||||||
|
}),
|
||||||
|
/non-negative/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("circuit device-row structure project commands", () => {
|
||||||
|
const row = {
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
legacyConsumerId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
costGroup: null,
|
||||||
|
category: "lighting",
|
||||||
|
level: null,
|
||||||
|
roomId: null,
|
||||||
|
roomNumberSnapshot: null,
|
||||||
|
roomNameSnapshot: null,
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: 0.9,
|
||||||
|
remark: null,
|
||||||
|
overriddenFields: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("captures complete insert and delete identities", () => {
|
||||||
|
const insert = createCircuitDeviceRowInsertProjectCommand(row);
|
||||||
|
const remove = createCircuitDeviceRowDeleteProjectCommand(
|
||||||
|
row.id,
|
||||||
|
row.circuitId
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(insert.payload.row, row);
|
||||||
|
assert.deepEqual(remove.payload, {
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects incomplete snapshots and invalid values", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
assertCircuitDeviceRowInsertProjectCommand({
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit-device-row.insert",
|
||||||
|
payload: {
|
||||||
|
row: {
|
||||||
|
...row,
|
||||||
|
linkedProjectDeviceId: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/linkedProjectDeviceId/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitDeviceRowInsertProjectCommand({
|
||||||
|
...row,
|
||||||
|
quantity: -1,
|
||||||
|
}),
|
||||||
|
/must not be negative/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => createCircuitDeviceRowDeleteProjectCommand("", "circuit-1"),
|
||||||
|
/rowId/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("circuit device-row move project commands", () => {
|
||||||
|
const targetCircuit = {
|
||||||
|
id: "circuit-new",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "section-1",
|
||||||
|
equipmentIdentifier: "-1F3",
|
||||||
|
displayName: "Neuer Stromkreis",
|
||||||
|
sortOrder: 30,
|
||||||
|
protectionType: null,
|
||||||
|
protectionRatedCurrent: null,
|
||||||
|
protectionCharacteristic: null,
|
||||||
|
cableType: null,
|
||||||
|
cableCrossSection: null,
|
||||||
|
cableLength: null,
|
||||||
|
rcdAssignment: null,
|
||||||
|
terminalDesignation: null,
|
||||||
|
voltage: null,
|
||||||
|
controlRequirement: null,
|
||||||
|
status: null,
|
||||||
|
isReserve: true,
|
||||||
|
remark: null,
|
||||||
|
deviceRows: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("captures deterministic source and target positions", () => {
|
||||||
|
const command = createCircuitDeviceRowMoveProjectCommand([
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: "circuit-2",
|
||||||
|
targetSortOrder: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "row-2",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetCircuitId: "circuit-2",
|
||||||
|
targetSortOrder: 40,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(command.payload.moves.length, 2);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertCircuitDeviceRowMoveProjectCommand(command)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty, duplicate and no-op moves", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => createCircuitDeviceRowMoveProjectCommand([]),
|
||||||
|
/at least one move/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitDeviceRowMoveProjectCommand([
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: "circuit-2",
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 30,
|
||||||
|
targetCircuitId: "circuit-2",
|
||||||
|
targetSortOrder: 40,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
/duplicate row ids/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitDeviceRowMoveProjectCommand([
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: "circuit-1",
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
/no-op/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures creation and deletion of a deterministic move target", () => {
|
||||||
|
const create =
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
"create",
|
||||||
|
targetCircuit,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: targetCircuit.id,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const remove =
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
"delete",
|
||||||
|
targetCircuit,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: targetCircuit.id,
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: "circuit-1",
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
create
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.equal(remove.payload.targetCircuitAction, "delete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects inconsistent move targets and non-empty snapshots", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
"create",
|
||||||
|
targetCircuit,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: "other-circuit",
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
/move rows into the new circuit/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand({
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit-device-row.move-with-new-circuit",
|
||||||
|
payload: {
|
||||||
|
targetCircuitAction: "create",
|
||||||
|
targetCircuit: {
|
||||||
|
...targetCircuit,
|
||||||
|
isReserve: false,
|
||||||
|
},
|
||||||
|
moves: [
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: targetCircuit.id,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/reserve state/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("circuit structure project commands", () => {
|
||||||
|
const row = {
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
legacyConsumerId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
costGroup: null,
|
||||||
|
category: "lighting",
|
||||||
|
level: null,
|
||||||
|
roomId: null,
|
||||||
|
roomNumberSnapshot: null,
|
||||||
|
roomNameSnapshot: null,
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: null,
|
||||||
|
remark: null,
|
||||||
|
overriddenFields: null,
|
||||||
|
};
|
||||||
|
const circuit = {
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "section-1",
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Beleuchtung",
|
||||||
|
sortOrder: 10,
|
||||||
|
protectionType: null,
|
||||||
|
protectionRatedCurrent: null,
|
||||||
|
protectionCharacteristic: null,
|
||||||
|
cableType: null,
|
||||||
|
cableCrossSection: null,
|
||||||
|
cableLength: null,
|
||||||
|
rcdAssignment: null,
|
||||||
|
terminalDesignation: null,
|
||||||
|
voltage: 230,
|
||||||
|
controlRequirement: "DALI",
|
||||||
|
status: null,
|
||||||
|
isReserve: false,
|
||||||
|
remark: null,
|
||||||
|
deviceRows: [row],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("captures complete circuit blocks and delete identities", () => {
|
||||||
|
const insert = createCircuitInsertProjectCommand(circuit);
|
||||||
|
const remove = createCircuitDeleteProjectCommand(
|
||||||
|
circuit.id,
|
||||||
|
circuit.circuitListId
|
||||||
|
);
|
||||||
|
assert.deepEqual(insert.payload.circuit, circuit);
|
||||||
|
assert.deepEqual(remove.payload, {
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedCircuitListId: "list-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects inconsistent reserve state, row ownership and duplicate ids", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitInsertProjectCommand({
|
||||||
|
...circuit,
|
||||||
|
isReserve: true,
|
||||||
|
}),
|
||||||
|
/reserve state/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitInsertProjectCommand({
|
||||||
|
...circuit,
|
||||||
|
deviceRows: [{ ...row, circuitId: "other-circuit" }],
|
||||||
|
}),
|
||||||
|
/different circuit/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitInsertProjectCommand({
|
||||||
|
...circuit,
|
||||||
|
deviceRows: [row, { ...row }],
|
||||||
|
}),
|
||||||
|
/duplicate row ids/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
assertCircuitInsertProjectCommand({
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.insert",
|
||||||
|
payload: {
|
||||||
|
circuit: { ...circuit, equipmentIdentifier: "" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/equipmentIdentifier/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
|
||||||
|
import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-project-command.repository.js";
|
||||||
|
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
|
||||||
|
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
|
||||||
|
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
|
||||||
|
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||||
|
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
|
||||||
|
import {
|
||||||
|
createCircuitDeviceRowMoveProjectCommand,
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand,
|
||||||
|
} from "../src/domain/models/circuit-device-row-move-project-command.model.js";
|
||||||
|
import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
|
||||||
|
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
||||||
|
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values({ id: "project-1", name: "Test project" })
|
||||||
|
.run();
|
||||||
|
const board = new DistributionBoardRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Bestand",
|
||||||
|
sortOrder: 10,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values({
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(context: DatabaseContext) {
|
||||||
|
return new ProjectCommandService(
|
||||||
|
new CircuitProjectCommandRepository(context.db),
|
||||||
|
new CircuitDeviceRowProjectCommandRepository(context.db),
|
||||||
|
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
|
||||||
|
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
|
||||||
|
new CircuitStructureProjectCommandRepository(context.db),
|
||||||
|
new ProjectHistoryRepository(context.db)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCircuitName(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select({ displayName: circuits.displayName })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get()?.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowQuantity(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select({ quantity: circuitDeviceRows.quantity })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get()?.quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project command service", () => {
|
||||||
|
it("dispatches supported commands and persists undo/redo across service instances", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const service = createService(context);
|
||||||
|
const circuitResult = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: "Neu",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(circuitResult.revision.revisionNumber, 1);
|
||||||
|
assert.equal(circuitResult.history.undoDepth, 1);
|
||||||
|
assert.equal(getCircuitName(context), "Neu");
|
||||||
|
|
||||||
|
const rowResult = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: 2,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(rowResult.revision.revisionNumber, 2);
|
||||||
|
assert.equal(rowResult.history.undoDepth, 2);
|
||||||
|
assert.equal(getRowQuantity(context), 2);
|
||||||
|
|
||||||
|
const reloadedService = createService(context);
|
||||||
|
const firstUndo = reloadedService.undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
});
|
||||||
|
assert.equal(firstUndo.revision.revisionNumber, 3);
|
||||||
|
assert.equal(firstUndo.history.undoDepth, 1);
|
||||||
|
assert.equal(firstUndo.history.redoDepth, 1);
|
||||||
|
assert.equal(getRowQuantity(context), 1);
|
||||||
|
|
||||||
|
const secondUndo = reloadedService.undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 3,
|
||||||
|
});
|
||||||
|
assert.equal(secondUndo.revision.revisionNumber, 4);
|
||||||
|
assert.equal(secondUndo.history.undoDepth, 0);
|
||||||
|
assert.equal(secondUndo.history.redoDepth, 2);
|
||||||
|
assert.equal(getCircuitName(context), "Bestand");
|
||||||
|
|
||||||
|
const redo = reloadedService.redo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 4,
|
||||||
|
});
|
||||||
|
assert.equal(redo.revision.revisionNumber, 5);
|
||||||
|
assert.equal(redo.history.undoDepth, 1);
|
||||||
|
assert.equal(redo.history.redoDepth, 1);
|
||||||
|
assert.equal(getCircuitName(context), "Neu");
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({ source: projectRevisions.source })
|
||||||
|
.from(projectRevisions)
|
||||||
|
.all()
|
||||||
|
.map((row) => row.source),
|
||||||
|
["user", "user", "undo", "undo", "redo"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale undo and preserves the domain value and stack", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const service = createService(context);
|
||||||
|
const changed = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: "Neu",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
service.undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error instanceof ProjectRevisionConflictError &&
|
||||||
|
error.actualRevision === 1
|
||||||
|
);
|
||||||
|
assert.equal(getCircuitName(context), "Neu");
|
||||||
|
assert.deepEqual(
|
||||||
|
new ProjectHistoryRepository(context.db).getState("project-1"),
|
||||||
|
changed.history
|
||||||
|
);
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
|
||||||
|
assert.equal(context.db.select().from(projectChangeSets).all().length, 1);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches device-row insertion and its persisted inverse", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const service = createService(context);
|
||||||
|
const inserted = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createCircuitDeviceRowInsertProjectCommand({
|
||||||
|
id: "row-2",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
legacyConsumerId: null,
|
||||||
|
sortOrder: 20,
|
||||||
|
name: "Steckdose",
|
||||||
|
displayName: "Steckdose",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
costGroup: null,
|
||||||
|
category: null,
|
||||||
|
level: null,
|
||||||
|
roomId: null,
|
||||||
|
roomNumberSnapshot: null,
|
||||||
|
roomNameSnapshot: null,
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: null,
|
||||||
|
remark: null,
|
||||||
|
overriddenFields: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(inserted.history.undoDepth, 1);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-2"))
|
||||||
|
.get()?.displayName,
|
||||||
|
"Steckdose"
|
||||||
|
);
|
||||||
|
|
||||||
|
const undone = createService(context).undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.equal(undone.history.redoDepth, 1);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-2"))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches complete circuit insertion and its persisted inverse", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const existingCircuit = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get();
|
||||||
|
assert.ok(existingCircuit);
|
||||||
|
const service = createService(context);
|
||||||
|
const inserted = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createCircuitInsertProjectCommand({
|
||||||
|
id: "circuit-new",
|
||||||
|
circuitListId: existingCircuit.circuitListId,
|
||||||
|
sectionId: existingCircuit.sectionId,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
displayName: "Reserve",
|
||||||
|
sortOrder: 20,
|
||||||
|
protectionType: null,
|
||||||
|
protectionRatedCurrent: null,
|
||||||
|
protectionCharacteristic: null,
|
||||||
|
cableType: null,
|
||||||
|
cableCrossSection: null,
|
||||||
|
cableLength: null,
|
||||||
|
rcdAssignment: null,
|
||||||
|
terminalDesignation: null,
|
||||||
|
voltage: 230,
|
||||||
|
controlRequirement: null,
|
||||||
|
status: null,
|
||||||
|
isReserve: true,
|
||||||
|
remark: null,
|
||||||
|
deviceRows: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(inserted.history.undoDepth, 1);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-new"))
|
||||||
|
.get()?.equipmentIdentifier,
|
||||||
|
"-1F2"
|
||||||
|
);
|
||||||
|
|
||||||
|
const undone = createService(context).undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.equal(undone.history.redoDepth, 1);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-new"))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches deterministic device-row moves and their inverse", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const sourceCircuit = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get();
|
||||||
|
assert.ok(sourceCircuit);
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-2",
|
||||||
|
circuitListId: sourceCircuit.circuitListId,
|
||||||
|
sectionId: sourceCircuit.sectionId,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 20,
|
||||||
|
isReserve: 1,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const service = createService(context);
|
||||||
|
const moved = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createCircuitDeviceRowMoveProjectCommand([
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: "circuit-2",
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
assert.equal(moved.history.undoDepth, 1);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get()?.circuitId,
|
||||||
|
"circuit-2"
|
||||||
|
);
|
||||||
|
|
||||||
|
createService(context).undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get()?.circuitId,
|
||||||
|
"circuit-1"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches placeholder moves that create and remove a circuit", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const sourceCircuit = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get();
|
||||||
|
assert.ok(sourceCircuit);
|
||||||
|
const targetCircuit = {
|
||||||
|
id: "circuit-placeholder-target",
|
||||||
|
circuitListId: sourceCircuit.circuitListId,
|
||||||
|
sectionId: sourceCircuit.sectionId,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
displayName: "Neuer Stromkreis",
|
||||||
|
sortOrder: 20,
|
||||||
|
protectionType: null,
|
||||||
|
protectionRatedCurrent: null,
|
||||||
|
protectionCharacteristic: null,
|
||||||
|
cableType: null,
|
||||||
|
cableCrossSection: null,
|
||||||
|
cableLength: null,
|
||||||
|
rcdAssignment: null,
|
||||||
|
terminalDesignation: null,
|
||||||
|
voltage: null,
|
||||||
|
controlRequirement: null,
|
||||||
|
status: null,
|
||||||
|
isReserve: true,
|
||||||
|
remark: null,
|
||||||
|
deviceRows: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const moved = createService(context).executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command:
|
||||||
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
"create",
|
||||||
|
targetCircuit,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: targetCircuit.id,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(moved.history.undoDepth, 1);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get()?.circuitId,
|
||||||
|
targetCircuit.id
|
||||||
|
);
|
||||||
|
|
||||||
|
createService(context).undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, targetCircuit.id))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get()?.circuitId,
|
||||||
|
"circuit-1"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unavailable history directions without writing a revision", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const service = createService(context);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
service.undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error instanceof ProjectHistoryOperationUnavailableError &&
|
||||||
|
error.direction === "undo"
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
service.redo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error instanceof ProjectHistoryOperationUnavailableError &&
|
||||||
|
error.direction === "redo"
|
||||||
|
);
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported and malformed commands before domain writes", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const service = createService(context);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "unknown.command",
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/Unsupported project command type/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.update",
|
||||||
|
payload: { circuitId: "circuit-1", changes: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/at least one change/
|
||||||
|
);
|
||||||
|
assert.equal(getCircuitName(context), "Bestand");
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
|
||||||
|
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||||
|
import { projectHistoryStackEntries } from "../src/db/schema/project-history-stack-entries.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
|
||||||
|
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values({ id: "project-1", name: "Test project" })
|
||||||
|
.run();
|
||||||
|
const board = new DistributionBoardRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Bestand",
|
||||||
|
sortOrder: 10,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values({
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCircuitName(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select({ displayName: circuits.displayName })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get()?.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowQuantity(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select({ quantity: circuitDeviceRows.quantity })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get()?.quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project history repository", () => {
|
||||||
|
it("persists project-wide undo and redo stacks across repository instances", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const circuitsStore = new CircuitProjectCommandRepository(context.db);
|
||||||
|
const rowsStore = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||||
|
const circuitCommand = createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: "Neu",
|
||||||
|
});
|
||||||
|
const circuitUpdate = circuitsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: circuitCommand,
|
||||||
|
});
|
||||||
|
const rowCommand = createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: 2,
|
||||||
|
});
|
||||||
|
const rowUpdate = rowsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: rowCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reloadedHistory = new ProjectHistoryRepository(context.db);
|
||||||
|
assert.deepEqual(reloadedHistory.getState("project-1"), {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 2,
|
||||||
|
undoDepth: 2,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: rowUpdate.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
circuitsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: circuitUpdate.revision.changeSetId,
|
||||||
|
command: circuitUpdate.inverse,
|
||||||
|
}),
|
||||||
|
/not the top undo command/
|
||||||
|
);
|
||||||
|
assert.equal(getCircuitName(context), "Neu");
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, "project-1"))
|
||||||
|
.get()?.currentRevision,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
rowsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: rowUpdate.revision.changeSetId,
|
||||||
|
command: rowUpdate.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(getRowQuantity(context), 1);
|
||||||
|
assert.deepEqual(reloadedHistory.getState("project-1"), {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 1,
|
||||||
|
undoChangeSetId: circuitUpdate.revision.changeSetId,
|
||||||
|
redoChangeSetId: rowUpdate.revision.changeSetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
circuitsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 3,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: circuitUpdate.revision.changeSetId,
|
||||||
|
command: circuitUpdate.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(getCircuitName(context), "Bestand");
|
||||||
|
assert.deepEqual(reloadedHistory.getState("project-1"), {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 4,
|
||||||
|
undoDepth: 0,
|
||||||
|
redoDepth: 2,
|
||||||
|
undoChangeSetId: null,
|
||||||
|
redoChangeSetId: circuitUpdate.revision.changeSetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
circuitsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 4,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: circuitUpdate.revision.changeSetId,
|
||||||
|
command: circuitCommand,
|
||||||
|
});
|
||||||
|
assert.equal(getCircuitName(context), "Neu");
|
||||||
|
assert.deepEqual(reloadedHistory.getState("project-1"), {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 5,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 1,
|
||||||
|
undoChangeSetId: circuitUpdate.revision.changeSetId,
|
||||||
|
redoChangeSetId: rowUpdate.revision.changeSetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const branchedRowUpdate = rowsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 5,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: 3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(getRowQuantity(context), 3);
|
||||||
|
assert.deepEqual(reloadedHistory.getState("project-1"), {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 6,
|
||||||
|
undoDepth: 2,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: branchedRowUpdate.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
});
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 6);
|
||||||
|
assert.equal(context.db.select().from(projectChangeSets).all().length, 6);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectHistoryStackEntries).all().length,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back domain and revision writes when stack persistence fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_history_stack_insert
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced history stack failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const store = new CircuitProjectCommandRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: "Darf nicht bleiben",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/forced history stack failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(getCircuitName(context), "Bestand");
|
||||||
|
assert.deepEqual(
|
||||||
|
new ProjectHistoryRepository(context.db).getState("project-1"),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 0,
|
||||||
|
undoDepth: 0,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: null,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an unknown project", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
assert.equal(
|
||||||
|
new ProjectHistoryRepository(context.db).getState("missing"),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import {
|
||||||
|
ProjectRevisionConflictError,
|
||||||
|
ProjectRevisionRepository,
|
||||||
|
} from "../src/db/repositories/project-revision.repository.js";
|
||||||
|
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTestRevision(
|
||||||
|
repository: ProjectRevisionRepository,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return repository.append({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision,
|
||||||
|
source: "user",
|
||||||
|
description: "Stromkreis bearbeiten",
|
||||||
|
actorId: "test-user",
|
||||||
|
forward: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.update",
|
||||||
|
payload: { circuitId: "circuit-1", displayName: "Neu" },
|
||||||
|
},
|
||||||
|
inverse: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.update",
|
||||||
|
payload: { circuitId: "circuit-1", displayName: "Alt" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project revision repository", () => {
|
||||||
|
it("appends immutable revision metadata and payloads with a monotonic number", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectRevisionRepository(context.db);
|
||||||
|
|
||||||
|
assert.equal(repository.getCurrentRevision("project-1"), 0);
|
||||||
|
const appended = appendTestRevision(repository, 0);
|
||||||
|
|
||||||
|
assert.equal(appended.projectId, "project-1");
|
||||||
|
assert.equal(appended.revisionNumber, 1);
|
||||||
|
assert.equal(repository.getCurrentRevision("project-1"), 1);
|
||||||
|
|
||||||
|
const revision = context.db
|
||||||
|
.select()
|
||||||
|
.from(projectRevisions)
|
||||||
|
.where(eq(projectRevisions.id, appended.revisionId))
|
||||||
|
.get();
|
||||||
|
const changeSet = context.db
|
||||||
|
.select()
|
||||||
|
.from(projectChangeSets)
|
||||||
|
.where(eq(projectChangeSets.id, appended.changeSetId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
assert.deepEqual(revision, {
|
||||||
|
id: appended.revisionId,
|
||||||
|
projectId: "project-1",
|
||||||
|
revisionNumber: 1,
|
||||||
|
createdAtIso: appended.createdAtIso,
|
||||||
|
actorId: "test-user",
|
||||||
|
source: "user",
|
||||||
|
description: "Stromkreis bearbeiten",
|
||||||
|
});
|
||||||
|
assert.deepEqual(changeSet, {
|
||||||
|
id: appended.changeSetId,
|
||||||
|
projectRevisionId: appended.revisionId,
|
||||||
|
commandType: "circuit.update",
|
||||||
|
payloadSchemaVersion: 1,
|
||||||
|
forwardPayloadJson: JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.update",
|
||||||
|
payload: {
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
displayName: "Neu",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
inversePayloadJson: JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.update",
|
||||||
|
payload: {
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
displayName: "Alt",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const next = appendTestRevision(repository, 1);
|
||||||
|
assert.equal(next.revisionNumber, 2);
|
||||||
|
assert.equal(repository.getCurrentRevision("project-1"), 2);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a stale expected revision without writing history", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectRevisionRepository(context.db);
|
||||||
|
appendTestRevision(repository, 0);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => appendTestRevision(repository, 0),
|
||||||
|
(error) =>
|
||||||
|
error instanceof ProjectRevisionConflictError &&
|
||||||
|
error.expectedRevision === 0 &&
|
||||||
|
error.actualRevision === 1
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repository.getCurrentRevision("project-1"), 1);
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
|
||||||
|
assert.equal(context.db.select().from(projectChangeSets).all().length, 1);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the project counter and revision when change-set storage fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_project_change_set_insert
|
||||||
|
BEFORE INSERT ON project_change_sets
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced change-set failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new ProjectRevisionRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => appendTestRevision(repository, 0),
|
||||||
|
/forced change-set failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repository.getCurrentRevision("project-1"), 0);
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user