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