Compare commits
12 Commits
1cf26a932e
...
e1fc81a083
| Author | SHA1 | Date | |
|---|---|---|---|
| e1fc81a083 | |||
| d7b98de56a | |||
| 859fa4a42a | |||
| c1be85e408 | |||
| 0cedeaa959 | |||
| 4789e01aac | |||
| 214ad728cd | |||
| e4d22caf09 | |||
| 0f306d9a90 | |||
| 266bdb4749 | |||
| d23e7d990c | |||
| ac465a1cc0 |
@@ -21,6 +21,24 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
|
||||
`src/frontend/components/circuit-grid-*.ts` modules.
|
||||
- Critical multi-write commands use injected transaction repositories with real
|
||||
SQLite commit/rollback tests.
|
||||
- All supported runtime project-command stores, including full snapshot
|
||||
restoration, share
|
||||
`src/db/repositories/project-command-transaction.persistence.ts` for the
|
||||
atomic domain-write, revision and history transition boundary. Its applied
|
||||
forward-command variant preserves derived CircuitDeviceRow override metadata.
|
||||
- Low-level `appendProjectRevision` persistence is adapter-internal and tested
|
||||
directly; do not reintroduce a standalone runtime revision repository.
|
||||
- Runtime domain services receive narrow reader/store dependencies explicitly;
|
||||
concrete SQLite repositories are instantiated only under `src/server/composition`.
|
||||
- General application repositories also require an explicit `AppDatabase`;
|
||||
`src/server/composition/application-repositories.ts` owns their runtime
|
||||
instances, and controllers never import the global SQLite client.
|
||||
- The upgrade-only legacy migration service follows the same dependency rule;
|
||||
its concrete repositories are instantiated only in
|
||||
`scripts/db-migrate-legacy-consumers.ts`.
|
||||
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
|
||||
repositories expose only active reads. Runtime writes belong in typed command
|
||||
repositories; direct integration fixtures belong under `tests/support`.
|
||||
|
||||
The supported runtime model is Circuit-First. The former Consumer UI and API are
|
||||
removed. Retained `consumers` rows, migration mappings and reports are upgrade-only
|
||||
@@ -272,6 +290,11 @@ Distribution-board setup uses `distribution-board.insert` with a complete
|
||||
stable snapshot of the board, circuit list and four default sections. Its
|
||||
inverse removes only the same unchanged and still-empty structure; the POST
|
||||
route requires `expectedRevision` and returns the updated history state.
|
||||
Floor and room setup use `project-floor.insert` and `project-room.insert` with
|
||||
complete stable snapshots. Their inverses remove only unchanged records and
|
||||
reject floors with assigned rooms or rooms referenced by device rows or
|
||||
retained upgrade data. Both POST routes require `expectedRevision` and return
|
||||
the updated history state.
|
||||
|
||||
Required operations:
|
||||
|
||||
@@ -341,6 +364,7 @@ For a normal code change run the relevant focused tests plus:
|
||||
- `npm test`
|
||||
- `npm run build:api`
|
||||
- `npm run build:web`
|
||||
- `npm run typecheck:scripts`
|
||||
- `npx tsc --noEmit -p tsconfig.next.json`
|
||||
|
||||
Use a concise imperative commit message for each completed, verified work package.
|
||||
|
||||
@@ -190,6 +190,23 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||
structure; Redo restores the same ids
|
||||
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
||||
|
||||
### Project Floors and Rooms
|
||||
|
||||
- `POST /projects/:projectId/floors`
|
||||
- body: `{ "name": "EG", "expectedRevision": 13 }`
|
||||
- executes `project-floor.insert` with a stable floor id
|
||||
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
- `POST /projects/:projectId/rooms`
|
||||
- body:
|
||||
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }`
|
||||
- executes `project-room.insert` with a stable room id; `floorId` is optional
|
||||
and must belong to the project when present
|
||||
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
- Persistent Undo removes only unchanged, unreferenced records. A floor with
|
||||
assigned rooms and a room referenced by device rows or retained upgrade data
|
||||
are rejected instead of silently clearing foreign keys.
|
||||
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
|
||||
|
||||
### Tree Endpoint
|
||||
|
||||
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
||||
|
||||
@@ -47,6 +47,8 @@ liegt über einen Host-Mount außerhalb des Containers.
|
||||
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe
|
||||
- `src/server/index.ts` und `src/server/routes/` – API-Komposition
|
||||
- `src/domain/services/` – fachliche Command- und Synchronisierungsregeln
|
||||
- `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten
|
||||
SQLite-Repositories
|
||||
- `src/db/repositories/` – Abfragen, Persistenzmapper und Transaktionsadapter
|
||||
- `src/db/schema/` und `src/db/migrations/` – SQLite-Schema und Migrationen
|
||||
|
||||
@@ -66,11 +68,13 @@ Der Editor besitzt keinen sitzungslokalen Undo-/Redo-Stapel mehr. Beim initialen
|
||||
Laden und nach jedem Tree-Reload liest er den persistenten History-Status und
|
||||
gleicht dessen Revision mit `currentRevision` des Trees ab. Das Datenmodell
|
||||
besitzt einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
|
||||
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
|
||||
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
|
||||
Tabellen. Eine direkt getestete Persistence-Funktion schreibt diese
|
||||
Historienmetadaten innerhalb der zentralen Command-Transaktion optimistisch und
|
||||
atomar fort. Vorwärts- und Rückwärtskommandos besitzen einen
|
||||
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
|
||||
einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten
|
||||
Circuit-, Gerätezeilen-, Projektgeräte- und Projekteinstellungsänderungen
|
||||
Circuit-, Gerätezeilen-, Projektgeräte-, Projektstruktur- und
|
||||
Projekteinstellungsänderungen
|
||||
verwenden typisierte Command-Stores, die Fachänderung, automatisch erzeugtes
|
||||
inverses Kommando, Revision und Historienstapel gemeinsam committen
|
||||
beziehungsweise zurückrollen.
|
||||
@@ -83,6 +87,26 @@ absteigend paginierte Revisions-Timeline ist ohne Befehls-Payloads über
|
||||
`GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler
|
||||
Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über
|
||||
öffentliche Command-, Undo- und Redo-Endpunkte aus.
|
||||
Alle unterstützten Runtime-Project-Command-Stores einschließlich der
|
||||
vollständigen Snapshot-Wiederherstellung verwenden dabei
|
||||
`project-command-transaction.persistence.ts` als gemeinsame äußere
|
||||
Transaktionsgrenze. Sie führt Fachänderung, Revisions-Append und
|
||||
History-Transition in fester Reihenfolge innerhalb derselben SQLite-Transaktion
|
||||
aus. Für CircuitDeviceRow-Feldänderungen übernimmt eine typisierte Variante den
|
||||
während der Fachänderung um Override-Metadaten ergänzten Forward-Command. Die
|
||||
fachlichen Validierungs-, Forward- und Inversenregeln bleiben im jeweiligen
|
||||
Store.
|
||||
Die Low-Level-Funktion `appendProjectRevision` bleibt ein internes Detail dieser
|
||||
Persistenzgrenze und wird direkt mit einer realen SQLite-Transaktion getestet.
|
||||
Ein eigenständiges Runtime-Revisions-Repository existiert nicht.
|
||||
`ProjectDeviceSyncService` und `CircuitNumberingService` kennen nur schmale,
|
||||
fachlich benannte Reader-Interfaces. Ihre SQLite-Repositories werden
|
||||
ausschließlich in `src/server/composition/` erzeugt und injiziert.
|
||||
Auch die allgemeinen Projekt-, Geräte-, Raum-, Geschoss- und Circuit-
|
||||
Repositories verlangen einen expliziten `AppDatabase`-Kontext. Ihre
|
||||
Anwendungsinstanzen werden zentral in
|
||||
`src/server/composition/application-repositories.ts` erzeugt; Controller
|
||||
importieren weder den globalen SQLite-Client noch konkrete Repository-Klassen.
|
||||
`circuit-device-row.insert` und `circuit-device-row.delete` sind atomare
|
||||
Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen
|
||||
Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte wiederherstellt.
|
||||
@@ -198,6 +222,14 @@ Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
||||
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
|
||||
Projektseite übergeben die erwartete Projektrevision; der frühere direkte
|
||||
Controller-Schreibweg ist entfernt.
|
||||
`project-floor.insert` und `project-room.insert` versionieren die Anlage von
|
||||
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
|
||||
jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur
|
||||
entfernt, solange ihm kein Raum zugeordnet wurde. Ein Raum wird nur entfernt,
|
||||
solange weder eine CircuitDeviceRow noch ein aufbewahrter Upgrade-Datensatz auf
|
||||
ihn verweist. Beide POST-Endpunkte verlangen `expectedRevision`, liefern den
|
||||
aktualisierten Historienstand und besitzen keinen direkten Create-Schreibweg
|
||||
mehr.
|
||||
|
||||
## Projektgeräte
|
||||
|
||||
@@ -220,8 +252,20 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
||||
- `npm run db:migrate` wendet Drizzle-Migrationen an.
|
||||
- `npm run db:verify:circuit-schema` prüft erforderliche und entfernte Spalten.
|
||||
- `npm run db:backup` erzeugt ein konsistentes und verifiziertes Online-Backup.
|
||||
- `npm run typecheck:scripts` prüft alle TypeScript-Wartungs- und
|
||||
Upgrade-Skripte mit ihren Anwendungspfaden, ohne Code zu erzeugen.
|
||||
- Angewendete Migrationen werden niemals nachträglich verändert.
|
||||
- `db:migrate:legacy-consumers` ist Upgrade-Werkzeug, kein Anwendungspfad.
|
||||
Der fachliche Migrationsdienst kennt nur schmale Reader-/Store-Ports unter
|
||||
`src/domain/ports`; konkrete SQLite-Repositories werden ausschließlich im
|
||||
CLI-Skript zusammengesetzt.
|
||||
- Allgemeine Circuit-, Gerätezeilen-, CircuitList- und DistributionBoard-
|
||||
Repositories stellen im Anwendungspfad nur noch benötigte Leseabfragen bereit.
|
||||
Fachliche Schreibvorgänge liegen in den typisierten Command-Repositories;
|
||||
Upgrade-Schreibvorgänge bleiben in expliziten Migrationsadaptern.
|
||||
- Die vollständige Verteilungs-Testfixture liegt unter
|
||||
`tests/support/distribution-board-fixture.ts` und ist kein exportierter
|
||||
Produktions-Schreibweg.
|
||||
|
||||
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
||||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||
|
||||
@@ -70,6 +70,7 @@ Beispiel `Preserve circuit blocks during filtering`.
|
||||
npm test
|
||||
npm run build:api
|
||||
npm run build:web
|
||||
npm run typecheck:scripts
|
||||
npx tsc --noEmit -p tsconfig.next.json
|
||||
git diff --check
|
||||
```
|
||||
@@ -83,6 +84,9 @@ Invoke-WebRequest http://localhost:3000/health
|
||||
```
|
||||
|
||||
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.
|
||||
Testaufbau, der vollständige Projektstrukturen direkt einfügt, gehört nach
|
||||
`tests/support`. Produktions-Repositories dürfen nicht nur für Testfixtures
|
||||
erneut direkte Schreibmethoden erhalten.
|
||||
|
||||
## Migrationen
|
||||
|
||||
|
||||
@@ -270,11 +270,18 @@ Completed foundation:
|
||||
list and default-section structure with stable ids; its delete inverse
|
||||
refuses changed or populated structures and the project-page POST tracks the
|
||||
returned revision
|
||||
- `project-floor.insert` and `project-room.insert` persist complete location
|
||||
records with stable ids; their inverses reject changed or referenced records
|
||||
so Undo never clears room assignments or device-row links implicitly
|
||||
- the floor and room POST routes require the current expected revision and the
|
||||
project page tracks the returned history state
|
||||
|
||||
Remaining constraints before completing project version history:
|
||||
|
||||
- extend project-scoped commands only when further project mutations are
|
||||
deliberately added to history; the generic versioned envelope is complete
|
||||
- all current normal project-scoped runtime writes are behind the revision
|
||||
boundary; extend it whenever further project mutations are introduced
|
||||
- project creation remains initialization at revision zero, while global device
|
||||
templates remain outside any single project history
|
||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||
- keep database backup/restore checks separate from project history tests
|
||||
|
||||
|
||||
@@ -303,6 +303,8 @@ Acceptance criteria:
|
||||
|
||||
## Phase 11: Persistence and Transaction Foundation
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Goal:
|
||||
|
||||
Prepare the current SQLite implementation for persistent history and a later PostgreSQL adapter without changing user-facing behavior.
|
||||
@@ -326,8 +328,9 @@ Implemented foundation:
|
||||
|
||||
- database contexts can be created independently from the production singleton
|
||||
- SQLite foreign-key enforcement is enabled explicitly for every context
|
||||
- the distribution-board repository receives its database dependency explicitly
|
||||
- distribution-board setup has real in-memory SQLite commit and rollback coverage using production migrations
|
||||
- the read-only distribution-board repository receives its database dependency explicitly
|
||||
- distribution-board setup uses its persistent command repository with real
|
||||
in-memory SQLite commit and rollback coverage using production migrations
|
||||
- database backups use SQLite's online backup API and include committed WAL data
|
||||
- every backup is opened independently and checked for integrity and foreign-key violations
|
||||
- an integration test restores the backup into a separate database and verifies its snapshot contents
|
||||
@@ -339,9 +342,26 @@ Implemented foundation:
|
||||
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
|
||||
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
|
||||
- BMK swaps and section reorder have real SQLite commit and rollback coverage
|
||||
- the legacy consumer migration receives its database dependency at the script entry point
|
||||
- the legacy consumer migration service depends only on narrow domain ports;
|
||||
all concrete repositories are composed at the script entry point
|
||||
- legacy circuit, row, mapping and report writes have real late-failure rollback coverage
|
||||
- circuit and device-row persistence value mapping is separated from the general repositories
|
||||
- obsolete direct Circuit, CircuitDeviceRow, CircuitList, DistributionBoard and
|
||||
Project repository writes are removed; integration fixtures live under
|
||||
`tests/support` instead of production repositories
|
||||
- all supported runtime project-command stores, including complete snapshot
|
||||
restore, share one tested transaction wrapper for domain mutation, revision
|
||||
append and history transition; its applied-forward variant retains derived
|
||||
CircuitDeviceRow override metadata
|
||||
- low-level revision append persistence is tested directly; the unused
|
||||
standalone runtime revision repository and store interface are removed
|
||||
- runtime project-device synchronization and circuit-numbering services depend
|
||||
on narrow domain readers; server composition injects the SQLite repositories
|
||||
- all general application repositories require an explicit database context;
|
||||
server composition owns their runtime instances and controllers contain no
|
||||
direct global SQLite access
|
||||
- TypeScript maintenance and upgrade scripts have a dedicated no-emit
|
||||
typecheck, including all referenced application modules
|
||||
- the legacy consumer UI and application read/write endpoints are removed after verified data cutover
|
||||
- retained legacy rows are accessible only through explicit database upgrade tooling
|
||||
- project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields
|
||||
@@ -451,6 +471,9 @@ Implemented foundation:
|
||||
- distribution-board setup uses `distribution-board.insert` with a complete
|
||||
stable board/list/default-section snapshot; Undo removes only an unchanged
|
||||
empty setup and the former direct controller write is removed
|
||||
- floor and room setup use `project-floor.insert` and `project-room.insert`
|
||||
with stable ids; their inverses refuse assigned or referenced records and the
|
||||
former direct repository create methods are removed
|
||||
- revision metadata, change-set payloads and the project counter are committed
|
||||
in one SQLite transaction
|
||||
- a stale expected revision produces no history writes
|
||||
|
||||
+3
-2
@@ -13,9 +13,10 @@
|
||||
"build": "npm run build:api",
|
||||
"build:api": "tsc -p tsconfig.json",
|
||||
"build:web": "next build",
|
||||
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
||||
"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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"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.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:backup": "tsx scripts/db-backup.ts",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { db } from "../src/db/client.js";
|
||||
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
|
||||
import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js";
|
||||
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
|
||||
|
||||
const projectRepository = new ProjectRepository();
|
||||
const circuitListRepository = new CircuitListRepository();
|
||||
const circuitSectionRepository = new CircuitSectionRepository();
|
||||
const projectRepository = new ProjectRepository(db);
|
||||
const circuitListRepository = new CircuitListRepository(db);
|
||||
const circuitSectionRepository = new CircuitSectionRepository(db);
|
||||
|
||||
async function run() {
|
||||
const projects = await projectRepository.list();
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { db } from "../src/db/client.js";
|
||||
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
|
||||
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
|
||||
import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js";
|
||||
import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
|
||||
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
|
||||
import { RoomRepository } from "../src/db/repositories/room.repository.js";
|
||||
import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js";
|
||||
|
||||
const projectRepository = new ProjectRepository();
|
||||
const circuitListRepository = new CircuitListRepository();
|
||||
const projectRepository = new ProjectRepository(db);
|
||||
const circuitListRepository = new CircuitListRepository(db);
|
||||
const migrationRepository = new LegacyConsumerMigrationRepository(db);
|
||||
const migrationService = new LegacyConsumerMigrationService(
|
||||
migrationRepository
|
||||
);
|
||||
const migrationService = new LegacyConsumerMigrationService({
|
||||
circuitListReader: circuitListRepository,
|
||||
sectionStore: new CircuitSectionRepository(db),
|
||||
circuitReader: new CircuitRepository(db),
|
||||
roomReader: new RoomRepository(db),
|
||||
migrationStore: migrationRepository,
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const projects = await projectRepository.list();
|
||||
|
||||
@@ -204,14 +204,19 @@ export default function ProjectDetailPage() {
|
||||
|
||||
async function handleCreateFloor(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!projectId || !floorName.trim()) {
|
||||
if (!projectId || !project || !floorName.trim()) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await createFloor(projectId, { name: floorName.trim() });
|
||||
setFloors((current) => [...current, created]);
|
||||
const result = await createFloor(
|
||||
projectId,
|
||||
{ name: floorName.trim() },
|
||||
project.currentRevision
|
||||
);
|
||||
setFloors((current) => [...current, result.floor]);
|
||||
applyProjectRevision(result.history.currentRevision);
|
||||
setFloorName("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden.");
|
||||
@@ -222,18 +227,28 @@ export default function ProjectDetailPage() {
|
||||
|
||||
async function handleCreateRoom(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!projectId || !roomNumber.trim() || !roomName.trim()) {
|
||||
if (
|
||||
!projectId ||
|
||||
!project ||
|
||||
!roomNumber.trim() ||
|
||||
!roomName.trim()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await createRoom(projectId, {
|
||||
floorId: roomFloorId || undefined,
|
||||
roomNumber: roomNumber.trim(),
|
||||
roomName: roomName.trim(),
|
||||
});
|
||||
setRooms((current) => [...current, created]);
|
||||
const result = await createRoom(
|
||||
projectId,
|
||||
{
|
||||
floorId: roomFloorId || undefined,
|
||||
roomNumber: roomNumber.trim(),
|
||||
roomName: roomName.trim(),
|
||||
},
|
||||
project.currentRevision
|
||||
);
|
||||
setRooms((current) => [...current, result.room]);
|
||||
applyProjectRevision(result.history.currentRevision);
|
||||
setRoomNumber("");
|
||||
setRoomName("");
|
||||
setRoomFloorId("");
|
||||
|
||||
@@ -19,8 +19,7 @@ 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";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
interface PersistedMoveRow {
|
||||
id: string;
|
||||
@@ -36,29 +35,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
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 };
|
||||
});
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private assertSupportedCommand(
|
||||
|
||||
@@ -21,8 +21,7 @@ import {
|
||||
toCircuitDeviceRowPatchValues,
|
||||
type CircuitDeviceRowPatchInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
|
||||
|
||||
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||
|
||||
@@ -34,99 +33,95 @@ export class CircuitDeviceRowProjectCommandRepository
|
||||
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.");
|
||||
}
|
||||
return executeProjectCommandTransactionWithAppliedForward(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteCircuitDeviceRowUpdateCommandInput
|
||||
) {
|
||||
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.");
|
||||
}
|
||||
)
|
||||
.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 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 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 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 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 };
|
||||
});
|
||||
return {
|
||||
forward: appliedForward,
|
||||
inverse,
|
||||
};
|
||||
}
|
||||
|
||||
private assertLinkedProjectDevice(
|
||||
|
||||
@@ -21,8 +21,7 @@ import {
|
||||
assertCircuitDeviceRowReferencesInProject,
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
implements CircuitDeviceRowStructureProjectCommandStore
|
||||
@@ -30,30 +29,17 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
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 };
|
||||
});
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
input.source,
|
||||
input.command
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
|
||||
@@ -1,38 +1,18 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../client.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 { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import {
|
||||
toCircuitDeviceRowCreateValues,
|
||||
toCircuitDeviceRowUpdateValues,
|
||||
type CircuitDeviceRowCreateInput,
|
||||
type CircuitDeviceRowUpdateInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
export type {
|
||||
CircuitDeviceRowCreateInput,
|
||||
CircuitDeviceRowUpdateInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
|
||||
|
||||
export class CircuitDeviceRowRepository {
|
||||
async findById(rowId: string) {
|
||||
const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async countByCircuit(circuitId: string) {
|
||||
const rows = await db.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows).where(eq(circuitDeviceRows.circuitId, circuitId));
|
||||
return rows.length;
|
||||
}
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByCircuitList(circuitIds: string[]) {
|
||||
if (!circuitIds.length) {
|
||||
return [];
|
||||
}
|
||||
return db
|
||||
return this.database
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(inArray(circuitDeviceRows.circuitId, circuitIds))
|
||||
@@ -40,7 +20,7 @@ export class CircuitDeviceRowRepository {
|
||||
}
|
||||
|
||||
async listLinkedByProjectDevice(projectId: string, projectDeviceId: string) {
|
||||
return db
|
||||
return this.database
|
||||
.select({
|
||||
id: circuitDeviceRows.id,
|
||||
circuitId: circuitDeviceRows.circuitId,
|
||||
@@ -83,34 +63,4 @@ export class CircuitDeviceRowRepository {
|
||||
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
|
||||
}
|
||||
|
||||
async create(input: CircuitDeviceRowCreateInput) {
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input));
|
||||
return id;
|
||||
}
|
||||
|
||||
async update(
|
||||
rowId: string,
|
||||
input: CircuitDeviceRowUpdateInput
|
||||
) {
|
||||
await db
|
||||
.update(circuitDeviceRows)
|
||||
.set(toCircuitDeviceRowUpdateValues(input))
|
||||
.where(eq(circuitDeviceRows.id, rowId));
|
||||
}
|
||||
|
||||
async delete(rowId: string) {
|
||||
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
|
||||
}
|
||||
|
||||
async moveToCircuit(rowId: string, targetCircuitId: string, sortOrder: number) {
|
||||
await db
|
||||
.update(circuitDeviceRows)
|
||||
.set({
|
||||
circuitId: targetCircuitId,
|
||||
sortOrder,
|
||||
})
|
||||
.where(eq(circuitDeviceRows.id, rowId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,51 +1,17 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
|
||||
export class CircuitListRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByProject(projectId: string) {
|
||||
const db = this.database;
|
||||
return db.select().from(circuitLists).where(eq(circuitLists.projectId, projectId));
|
||||
}
|
||||
|
||||
async createForDistributionBoard(input: {
|
||||
projectId: string;
|
||||
distributionBoardId: string;
|
||||
name: string;
|
||||
}) {
|
||||
const entry = {
|
||||
id: input.distributionBoardId,
|
||||
projectId: input.projectId,
|
||||
distributionBoardId: input.distributionBoardId,
|
||||
name: input.name,
|
||||
};
|
||||
await db.insert(circuitLists).values(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async findByDistributionBoardId(projectId: string, distributionBoardId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitLists.projectId, projectId),
|
||||
eq(circuitLists.distributionBoardId, distributionBoardId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async existsInProject(projectId: string, circuitListId: string) {
|
||||
const [row] = await db
|
||||
.select({ id: circuitLists.id })
|
||||
.from(circuitLists)
|
||||
.where(and(eq(circuitLists.projectId, projectId), eq(circuitLists.id, circuitListId)))
|
||||
.limit(1);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
async findById(projectId: string, circuitListId: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
@@ -53,13 +19,4 @@ export class CircuitListRepository {
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findByIdByListIdOnly(circuitListId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, circuitListId))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
toCircuitPatchValues,
|
||||
type CircuitPatchPersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
type CircuitRow = typeof circuits.$inferSelect;
|
||||
|
||||
@@ -31,81 +30,74 @@ export class CircuitProjectCommandRepository
|
||||
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.");
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
const owningList = tx
|
||||
.select({ id: circuitLists.id })
|
||||
.from(circuitLists)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitLists.id, current.circuitListId),
|
||||
eq(circuitLists.projectId, input.projectId)
|
||||
)
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteCircuitUpdateCommandInput
|
||||
) {
|
||||
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.");
|
||||
}
|
||||
)
|
||||
.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 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 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 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 };
|
||||
});
|
||||
return inverse;
|
||||
}
|
||||
|
||||
private assertSectionInCircuitList(
|
||||
|
||||
@@ -12,8 +12,7 @@ import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitSectionRenumberProjectCommandRepository
|
||||
implements CircuitSectionRenumberProjectCommandStore
|
||||
@@ -23,190 +22,184 @@ export class CircuitSectionRenumberProjectCommandRepository
|
||||
execute(input: ExecuteCircuitSectionRenumberCommandInput) {
|
||||
assertCircuitSectionRenumberProjectCommand(input.command);
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const section = tx
|
||||
.select({
|
||||
id: circuitSections.id,
|
||||
circuitListId: circuitSections.circuitListId,
|
||||
projectId: circuitLists.projectId,
|
||||
})
|
||||
.from(circuitSections)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, circuitSections.circuitListId)
|
||||
)
|
||||
.where(eq(circuitSections.id, input.command.payload.sectionId))
|
||||
.get();
|
||||
if (!section || section.projectId !== input.projectId) {
|
||||
throw new Error(
|
||||
"Circuit section does not belong to project."
|
||||
);
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
const sectionCircuits = tx
|
||||
.select({
|
||||
id: circuits.id,
|
||||
circuitListId: circuits.circuitListId,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, section.id))
|
||||
.all();
|
||||
const assignments = input.command.payload.assignments;
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteCircuitSectionRenumberCommandInput
|
||||
) {
|
||||
const section = tx
|
||||
.select({
|
||||
id: circuitSections.id,
|
||||
circuitListId: circuitSections.circuitListId,
|
||||
projectId: circuitLists.projectId,
|
||||
})
|
||||
.from(circuitSections)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, circuitSections.circuitListId)
|
||||
)
|
||||
.where(eq(circuitSections.id, input.command.payload.sectionId))
|
||||
.get();
|
||||
if (!section || section.projectId !== input.projectId) {
|
||||
throw new Error(
|
||||
"Circuit section does not belong to project."
|
||||
);
|
||||
}
|
||||
|
||||
const sectionCircuits = tx
|
||||
.select({
|
||||
id: circuits.id,
|
||||
circuitListId: circuits.circuitListId,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, section.id))
|
||||
.all();
|
||||
const assignments = input.command.payload.assignments;
|
||||
if (
|
||||
sectionCircuits.length !== assignments.length ||
|
||||
sectionCircuits.some(
|
||||
(circuit) =>
|
||||
circuit.circuitListId !== section.circuitListId
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit renumber must include every circuit in the section."
|
||||
);
|
||||
}
|
||||
const circuitsById = new Map(
|
||||
sectionCircuits.map((circuit) => [circuit.id, circuit])
|
||||
);
|
||||
for (const assignment of assignments) {
|
||||
const circuit = circuitsById.get(assignment.circuitId);
|
||||
if (
|
||||
sectionCircuits.length !== assignments.length ||
|
||||
sectionCircuits.some(
|
||||
(circuit) =>
|
||||
circuit.circuitListId !== section.circuitListId
|
||||
)
|
||||
!circuit ||
|
||||
circuit.equipmentIdentifier !==
|
||||
assignment.expectedEquipmentIdentifier
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit renumber must include every circuit in the section."
|
||||
"Circuit changed before section renumber execution."
|
||||
);
|
||||
}
|
||||
const circuitsById = new Map(
|
||||
sectionCircuits.map((circuit) => [circuit.id, circuit])
|
||||
);
|
||||
for (const assignment of assignments) {
|
||||
const circuit = circuitsById.get(assignment.circuitId);
|
||||
if (
|
||||
!circuit ||
|
||||
circuit.equipmentIdentifier !==
|
||||
assignment.expectedEquipmentIdentifier
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit changed before section renumber execution."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listCircuits = tx
|
||||
.select({
|
||||
id: circuits.id,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.circuitListId, section.circuitListId))
|
||||
.all();
|
||||
const sectionCircuitIds = new Set(
|
||||
assignments.map((assignment) => assignment.circuitId)
|
||||
const listCircuits = tx
|
||||
.select({
|
||||
id: circuits.id,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.circuitListId, section.circuitListId))
|
||||
.all();
|
||||
const sectionCircuitIds = new Set(
|
||||
assignments.map((assignment) => assignment.circuitId)
|
||||
);
|
||||
const targetIdentifiers = new Set(
|
||||
assignments.map(
|
||||
(assignment) => assignment.targetEquipmentIdentifier
|
||||
)
|
||||
);
|
||||
const conflictingCircuit = listCircuits.find(
|
||||
(circuit) =>
|
||||
!sectionCircuitIds.has(circuit.id) &&
|
||||
targetIdentifiers.has(circuit.equipmentIdentifier)
|
||||
);
|
||||
if (conflictingCircuit) {
|
||||
throw new Error(
|
||||
"Target equipment identifier already exists in circuit list."
|
||||
);
|
||||
const targetIdentifiers = new Set(
|
||||
assignments.map(
|
||||
(assignment) => assignment.targetEquipmentIdentifier
|
||||
}
|
||||
|
||||
const inverse = createCircuitSectionRenumberProjectCommand(
|
||||
section.id,
|
||||
assignments.map((assignment) => ({
|
||||
circuitId: assignment.circuitId,
|
||||
expectedEquipmentIdentifier:
|
||||
assignment.targetEquipmentIdentifier,
|
||||
targetEquipmentIdentifier:
|
||||
assignment.expectedEquipmentIdentifier,
|
||||
}))
|
||||
);
|
||||
const temporaryIdentifiers = this.createTemporaryIdentifiers(
|
||||
section.id,
|
||||
assignments,
|
||||
listCircuits.map((circuit) => circuit.equipmentIdentifier)
|
||||
);
|
||||
|
||||
for (const assignment of assignments) {
|
||||
if (
|
||||
assignment.expectedEquipmentIdentifier ===
|
||||
assignment.targetEquipmentIdentifier
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const temporaryIdentifier = temporaryIdentifiers.get(
|
||||
assignment.circuitId
|
||||
)!;
|
||||
const updated = tx
|
||||
.update(circuits)
|
||||
.set({ equipmentIdentifier: temporaryIdentifier })
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.id, assignment.circuitId),
|
||||
eq(circuits.sectionId, section.id),
|
||||
eq(circuits.circuitListId, section.circuitListId),
|
||||
eq(
|
||||
circuits.equipmentIdentifier,
|
||||
assignment.expectedEquipmentIdentifier
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
const conflictingCircuit = listCircuits.find(
|
||||
(circuit) =>
|
||||
!sectionCircuitIds.has(circuit.id) &&
|
||||
targetIdentifiers.has(circuit.equipmentIdentifier)
|
||||
);
|
||||
if (conflictingCircuit) {
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Target equipment identifier already exists in circuit list."
|
||||
"Circuit changed during section renumber execution."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const inverse = createCircuitSectionRenumberProjectCommand(
|
||||
section.id,
|
||||
assignments.map((assignment) => ({
|
||||
circuitId: assignment.circuitId,
|
||||
expectedEquipmentIdentifier:
|
||||
for (const assignment of assignments) {
|
||||
if (
|
||||
assignment.expectedEquipmentIdentifier ===
|
||||
assignment.targetEquipmentIdentifier
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const temporaryIdentifier = temporaryIdentifiers.get(
|
||||
assignment.circuitId
|
||||
)!;
|
||||
const updated = tx
|
||||
.update(circuits)
|
||||
.set({
|
||||
equipmentIdentifier:
|
||||
assignment.targetEquipmentIdentifier,
|
||||
targetEquipmentIdentifier:
|
||||
assignment.expectedEquipmentIdentifier,
|
||||
}))
|
||||
);
|
||||
const temporaryIdentifiers = this.createTemporaryIdentifiers(
|
||||
section.id,
|
||||
assignments,
|
||||
listCircuits.map((circuit) => circuit.equipmentIdentifier)
|
||||
);
|
||||
|
||||
for (const assignment of assignments) {
|
||||
if (
|
||||
assignment.expectedEquipmentIdentifier ===
|
||||
assignment.targetEquipmentIdentifier
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const temporaryIdentifier = temporaryIdentifiers.get(
|
||||
assignment.circuitId
|
||||
)!;
|
||||
const updated = tx
|
||||
.update(circuits)
|
||||
.set({ equipmentIdentifier: temporaryIdentifier })
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.id, assignment.circuitId),
|
||||
eq(circuits.sectionId, section.id),
|
||||
eq(circuits.circuitListId, section.circuitListId),
|
||||
eq(
|
||||
circuits.equipmentIdentifier,
|
||||
assignment.expectedEquipmentIdentifier
|
||||
)
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.id, assignment.circuitId),
|
||||
eq(circuits.sectionId, section.id),
|
||||
eq(circuits.circuitListId, section.circuitListId),
|
||||
eq(
|
||||
circuits.equipmentIdentifier,
|
||||
temporaryIdentifier
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Circuit changed during section renumber execution."
|
||||
);
|
||||
}
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Circuit changed during final section renumber execution."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const assignment of assignments) {
|
||||
if (
|
||||
assignment.expectedEquipmentIdentifier ===
|
||||
assignment.targetEquipmentIdentifier
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const temporaryIdentifier = temporaryIdentifiers.get(
|
||||
assignment.circuitId
|
||||
)!;
|
||||
const updated = tx
|
||||
.update(circuits)
|
||||
.set({
|
||||
equipmentIdentifier:
|
||||
assignment.targetEquipmentIdentifier,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.id, assignment.circuitId),
|
||||
eq(circuits.sectionId, section.id),
|
||||
eq(circuits.circuitListId, section.circuitListId),
|
||||
eq(
|
||||
circuits.equipmentIdentifier,
|
||||
temporaryIdentifier
|
||||
)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Circuit changed during final section renumber execution."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward: input.command,
|
||||
inverse,
|
||||
});
|
||||
applyProjectHistoryTransition(tx, {
|
||||
projectId: input.projectId,
|
||||
source: input.source,
|
||||
recordedChangeSetId: revision.changeSetId,
|
||||
targetChangeSetId: input.historyTargetChangeSetId,
|
||||
});
|
||||
return { revision, inverse };
|
||||
});
|
||||
return inverse;
|
||||
}
|
||||
|
||||
private createTemporaryIdentifiers(
|
||||
|
||||
@@ -21,8 +21,7 @@ import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
type ReorderHistoryCommand =
|
||||
| CircuitSectionReorderProjectCommand
|
||||
@@ -36,54 +35,48 @@ export class CircuitSectionReorderProjectCommandRepository
|
||||
execute(input: ExecuteCircuitSectionReorderCommandInput) {
|
||||
this.assertSupportedCommand(input.command);
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const commandSections = this.getCommandSections(input.command);
|
||||
const inverseSections = commandSections.map((section) => {
|
||||
this.assertCompleteCurrentSection(
|
||||
tx,
|
||||
input.projectId,
|
||||
section
|
||||
);
|
||||
return {
|
||||
sectionId: section.sectionId,
|
||||
assignments: section.assignments.map((assignment) => ({
|
||||
circuitId: assignment.circuitId,
|
||||
expectedSortOrder: assignment.targetSortOrder,
|
||||
targetSortOrder: assignment.expectedSortOrder,
|
||||
})),
|
||||
};
|
||||
});
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
for (const section of commandSections) {
|
||||
this.applyAssignments(tx, section);
|
||||
}
|
||||
|
||||
const inverse =
|
||||
input.command.type === circuitSectionReorderCommandType
|
||||
? createCircuitSectionReorderProjectCommand(
|
||||
inverseSections[0].sectionId,
|
||||
inverseSections[0].assignments
|
||||
)
|
||||
: createCircuitSectionsReorderProjectCommand(
|
||||
inverseSections
|
||||
);
|
||||
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(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteCircuitSectionReorderCommandInput
|
||||
) {
|
||||
const commandSections = this.getCommandSections(input.command);
|
||||
const inverseSections = commandSections.map((section) => {
|
||||
this.assertCompleteCurrentSection(
|
||||
tx,
|
||||
input.projectId,
|
||||
section
|
||||
);
|
||||
return {
|
||||
sectionId: section.sectionId,
|
||||
assignments: section.assignments.map((assignment) => ({
|
||||
circuitId: assignment.circuitId,
|
||||
expectedSortOrder: assignment.targetSortOrder,
|
||||
targetSortOrder: assignment.expectedSortOrder,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
for (const section of commandSections) {
|
||||
this.applyAssignments(tx, section);
|
||||
}
|
||||
|
||||
const inverse =
|
||||
input.command.type === circuitSectionReorderCommandType
|
||||
? createCircuitSectionReorderProjectCommand(
|
||||
inverseSections[0].sectionId,
|
||||
inverseSections[0].assignments
|
||||
)
|
||||
: createCircuitSectionsReorderProjectCommand(
|
||||
inverseSections
|
||||
);
|
||||
return inverse;
|
||||
}
|
||||
|
||||
private assertSupportedCommand(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||
import { db } from "../client.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
|
||||
export function createDefaultCircuitSectionValues(circuitListId: string) {
|
||||
@@ -13,12 +13,16 @@ export function createDefaultCircuitSectionValues(circuitListId: string) {
|
||||
}
|
||||
|
||||
export class CircuitSectionRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async findById(sectionId: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db.select().from(circuitSections).where(eq(circuitSections.id, sectionId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async listByCircuitList(circuitListId: string) {
|
||||
const db = this.database;
|
||||
return db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
@@ -27,6 +31,7 @@ export class CircuitSectionRepository {
|
||||
}
|
||||
|
||||
async createDefaults(circuitListId: string) {
|
||||
const db = this.database;
|
||||
for (const entry of createDefaultCircuitSectionValues(circuitListId)) {
|
||||
const existing = await db
|
||||
.select({ id: circuitSections.id })
|
||||
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
assertCircuitDeviceRowReferencesInProject,
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitStructureProjectCommandRepository
|
||||
implements CircuitStructureProjectCommandStore
|
||||
@@ -31,30 +30,17 @@ export class CircuitStructureProjectCommandRepository
|
||||
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 };
|
||||
});
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
input.source,
|
||||
input.command
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq, ne } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import {
|
||||
toCircuitCreateValues,
|
||||
type CircuitCreatePersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
|
||||
export type {
|
||||
CircuitCreatePersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
export {
|
||||
toCircuitCreateValues,
|
||||
} from "./circuit.persistence.js";
|
||||
|
||||
export class CircuitRepository {
|
||||
async findById(circuitId: string) {
|
||||
const [row] = await db.select().from(circuits).where(eq(circuits.id, circuitId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByCircuitList(circuitListId: string) {
|
||||
const db = this.database;
|
||||
return db
|
||||
.select()
|
||||
.from(circuits)
|
||||
@@ -28,85 +14,12 @@ export class CircuitRepository {
|
||||
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
||||
}
|
||||
|
||||
async create(input: CircuitCreatePersistenceInput) {
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(circuits).values(toCircuitCreateValues(id, input));
|
||||
return id;
|
||||
}
|
||||
|
||||
async update(
|
||||
circuitId: string,
|
||||
input: {
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
displayName?: string;
|
||||
sortOrder: number;
|
||||
protectionType?: string;
|
||||
protectionRatedCurrent?: number;
|
||||
protectionCharacteristic?: string;
|
||||
cableType?: string;
|
||||
cableCrossSection?: string;
|
||||
cableLength?: number;
|
||||
rcdAssignment?: string;
|
||||
terminalDesignation?: string;
|
||||
voltage?: number;
|
||||
controlRequirement?: string;
|
||||
status?: string;
|
||||
isReserve: boolean;
|
||||
remark?: string;
|
||||
}
|
||||
) {
|
||||
await db
|
||||
.update(circuits)
|
||||
.set({
|
||||
sectionId: input.sectionId,
|
||||
equipmentIdentifier: input.equipmentIdentifier,
|
||||
displayName: input.displayName ?? null,
|
||||
sortOrder: input.sortOrder,
|
||||
protectionType: input.protectionType ?? null,
|
||||
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
|
||||
protectionCharacteristic: input.protectionCharacteristic ?? null,
|
||||
cableType: input.cableType ?? null,
|
||||
cableCrossSection: input.cableCrossSection ?? null,
|
||||
cableLength: input.cableLength ?? null,
|
||||
rcdAssignment: input.rcdAssignment ?? null,
|
||||
terminalDesignation: input.terminalDesignation ?? null,
|
||||
voltage: input.voltage ?? null,
|
||||
controlRequirement: input.controlRequirement ?? null,
|
||||
status: input.status ?? null,
|
||||
isReserve: input.isReserve ? 1 : 0,
|
||||
remark: input.remark ?? null,
|
||||
})
|
||||
.where(eq(circuits.id, circuitId));
|
||||
}
|
||||
|
||||
async delete(circuitId: string) {
|
||||
await db.delete(circuits).where(eq(circuits.id, circuitId));
|
||||
}
|
||||
|
||||
async existsByEquipmentIdentifier(circuitListId: string, equipmentIdentifier: string, excludeCircuitId?: string) {
|
||||
const rows = await db
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.where(
|
||||
excludeCircuitId
|
||||
? and(
|
||||
eq(circuits.circuitListId, circuitListId),
|
||||
eq(circuits.equipmentIdentifier, equipmentIdentifier),
|
||||
ne(circuits.id, excludeCircuitId)
|
||||
)
|
||||
: and(eq(circuits.circuitListId, circuitListId), eq(circuits.equipmentIdentifier, equipmentIdentifier))
|
||||
)
|
||||
.limit(1);
|
||||
return Boolean(rows.length);
|
||||
}
|
||||
|
||||
async listBySection(sectionId: string) {
|
||||
const db = this.database;
|
||||
return db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, sectionId))
|
||||
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class DistributionBoardStructureProjectCommandRepository
|
||||
implements DistributionBoardStructureProjectCommandStore
|
||||
@@ -28,29 +27,12 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteDistributionBoardStructureCommandInput) {
|
||||
return this.database.transaction((tx) => {
|
||||
const inverse = this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
input.command
|
||||
);
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward: input.command,
|
||||
inverse,
|
||||
});
|
||||
applyProjectHistoryTransition(tx, {
|
||||
projectId: input.projectId,
|
||||
source: input.source,
|
||||
recordedChangeSetId: revision.changeSetId,
|
||||
targetChangeSetId: input.historyTargetChangeSetId,
|
||||
});
|
||||
return { revision, inverse };
|
||||
});
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { createDefaultCircuitSectionValues } from "./circuit-section.repository.js";
|
||||
|
||||
export class DistributionBoardRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
@@ -16,44 +12,4 @@ export class DistributionBoardRepository {
|
||||
.where(eq(distributionBoards.projectId, projectId));
|
||||
}
|
||||
|
||||
async create(projectId: string, name: string) {
|
||||
const id = crypto.randomUUID();
|
||||
const board = { id, projectId, name };
|
||||
await this.database.insert(distributionBoards).values(board);
|
||||
return board;
|
||||
}
|
||||
|
||||
createWithCircuitListAndDefaultSections(projectId: string, name: string) {
|
||||
const id = crypto.randomUUID();
|
||||
const board = { id, projectId, name };
|
||||
const circuitList = {
|
||||
id,
|
||||
projectId,
|
||||
distributionBoardId: id,
|
||||
name: `${name} Stromkreisliste`,
|
||||
};
|
||||
const sections = createDefaultCircuitSectionValues(id);
|
||||
|
||||
this.database.transaction((tx) => {
|
||||
tx.insert(distributionBoards).values(board).run();
|
||||
tx.insert(circuitLists).values(circuitList).run();
|
||||
tx.insert(circuitSections).values(sections).run();
|
||||
});
|
||||
|
||||
return board;
|
||||
}
|
||||
|
||||
async existsInProject(projectId: string, distributionBoardId: string) {
|
||||
const [row] = await this.database
|
||||
.select({ id: distributionBoards.id })
|
||||
.from(distributionBoards)
|
||||
.where(
|
||||
and(
|
||||
eq(distributionBoards.projectId, projectId),
|
||||
eq(distributionBoards.id, distributionBoardId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return Boolean(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,16 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
|
||||
export class FloorRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByProject(projectId: string) {
|
||||
const db = this.database;
|
||||
return db
|
||||
.select()
|
||||
.from(floors)
|
||||
.where(eq(floors.projectId, projectId))
|
||||
.orderBy(asc(floors.sortOrder), asc(floors.name));
|
||||
}
|
||||
|
||||
async create(projectId: string, name: string) {
|
||||
const id = crypto.randomUUID();
|
||||
const existing = await this.listByProject(projectId);
|
||||
const floor = {
|
||||
id,
|
||||
projectId,
|
||||
name,
|
||||
sortOrder: existing.length,
|
||||
};
|
||||
await db.insert(floors).values(floor);
|
||||
return floor;
|
||||
}
|
||||
|
||||
async existsInProject(projectId: string, floorId: string) {
|
||||
const [row] = await db
|
||||
.select({ id: floors.id })
|
||||
.from(floors)
|
||||
.where(and(eq(floors.projectId, projectId), eq(floors.id, floorId)))
|
||||
.limit(1);
|
||||
return Boolean(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { globalDevices } from "../schema/global-devices.js";
|
||||
import type {
|
||||
CreateGlobalDeviceInput,
|
||||
@@ -8,11 +8,15 @@ import type {
|
||||
} from "../../shared/validation/global-device.schemas.js";
|
||||
|
||||
export class GlobalDeviceRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async list() {
|
||||
const db = this.database;
|
||||
return db.select().from(globalDevices);
|
||||
}
|
||||
|
||||
async create(input: CreateGlobalDeviceInput) {
|
||||
const db = this.database;
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(globalDevices).values({
|
||||
id,
|
||||
@@ -31,6 +35,7 @@ export class GlobalDeviceRepository {
|
||||
}
|
||||
|
||||
async update(globalDeviceId: string, input: UpdateGlobalDeviceInput) {
|
||||
const db = this.database;
|
||||
await db
|
||||
.update(globalDevices)
|
||||
.set({
|
||||
@@ -49,11 +54,13 @@ export class GlobalDeviceRepository {
|
||||
}
|
||||
|
||||
async findById(globalDeviceId: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db.select().from(globalDevices).where(eq(globalDevices.id, globalDeviceId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async delete(globalDeviceId: string) {
|
||||
const db = this.database;
|
||||
await db.delete(globalDevices).where(eq(globalDevices.id, globalDeviceId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,17 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq, inArray, isNull } from "drizzle-orm";
|
||||
import type {
|
||||
LegacyMigrationCircuitInput,
|
||||
LegacyMigrationReportInput,
|
||||
} from "../../domain/ports/legacy-consumer-migration.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { consumers } from "../schema/consumers.js";
|
||||
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
||||
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
||||
import {
|
||||
toCircuitDeviceRowCreateValues,
|
||||
type CircuitDeviceRowCreateInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
import {
|
||||
toCircuitCreateValues,
|
||||
type CircuitCreatePersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
|
||||
export interface LegacyMigrationCircuitPersistenceInput {
|
||||
circuit: CircuitCreatePersistenceInput;
|
||||
deviceRows: Array<
|
||||
Omit<CircuitDeviceRowCreateInput, "circuitId"> & { legacyConsumerId: string }
|
||||
>;
|
||||
}
|
||||
|
||||
export interface LegacyMigrationReportPersistenceInput {
|
||||
legacyConsumerCount: number;
|
||||
createdCircuitCount: number;
|
||||
createdDeviceRowCount: number;
|
||||
duplicateGroupedCount: number;
|
||||
generatedIdentifierCount: number;
|
||||
unassignedRowCount: number;
|
||||
warningsJson: string;
|
||||
generatedIdentifiersJson: string;
|
||||
duplicateGroupsJson: string;
|
||||
}
|
||||
import { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
|
||||
import { toCircuitCreateValues } from "./circuit.persistence.js";
|
||||
|
||||
export class LegacyConsumerMigrationRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
@@ -69,8 +48,8 @@ export class LegacyConsumerMigrationRepository {
|
||||
|
||||
persistCircuitListMigration(input: {
|
||||
circuitListId: string;
|
||||
circuits: LegacyMigrationCircuitPersistenceInput[];
|
||||
report: LegacyMigrationReportPersistenceInput;
|
||||
circuits: LegacyMigrationCircuitInput[];
|
||||
report: LegacyMigrationReportInput;
|
||||
}) {
|
||||
if (input.circuits.some((entry) => entry.circuit.circuitListId !== input.circuitListId)) {
|
||||
throw new Error("All migrated circuits must belong to the target circuit list.");
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "../../domain/ports/project-revision.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
|
||||
export interface ProjectCommandTransactionInput<
|
||||
Command extends SerializedProjectCommand<unknown>,
|
||||
> {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: Command;
|
||||
}
|
||||
|
||||
export interface AppliedProjectCommand<
|
||||
Forward extends SerializedProjectCommand<unknown>,
|
||||
Inverse extends SerializedProjectCommand<unknown>,
|
||||
> {
|
||||
forward: Forward;
|
||||
inverse: Inverse;
|
||||
}
|
||||
|
||||
export function executeProjectCommandTransaction<
|
||||
Command extends SerializedProjectCommand<unknown>,
|
||||
Inverse extends SerializedProjectCommand<unknown>,
|
||||
>(
|
||||
database: AppDatabase,
|
||||
input: ProjectCommandTransactionInput<Command>,
|
||||
applyCommand: (transaction: AppDatabase) => Inverse
|
||||
): { revision: AppendedProjectRevision; inverse: Inverse } {
|
||||
return executeAppliedProjectCommandTransaction(
|
||||
database,
|
||||
input,
|
||||
(transaction) => ({
|
||||
forward: input.command,
|
||||
inverse: applyCommand(transaction),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function executeProjectCommandTransactionWithAppliedForward<
|
||||
Command extends SerializedProjectCommand<unknown>,
|
||||
Forward extends SerializedProjectCommand<unknown>,
|
||||
Inverse extends SerializedProjectCommand<unknown>,
|
||||
>(
|
||||
database: AppDatabase,
|
||||
input: ProjectCommandTransactionInput<Command>,
|
||||
applyCommand: (
|
||||
transaction: AppDatabase
|
||||
) => AppliedProjectCommand<Forward, Inverse>
|
||||
): { revision: AppendedProjectRevision; inverse: Inverse } {
|
||||
return executeAppliedProjectCommandTransaction(
|
||||
database,
|
||||
input,
|
||||
applyCommand
|
||||
);
|
||||
}
|
||||
|
||||
function executeAppliedProjectCommandTransaction<
|
||||
Command extends SerializedProjectCommand<unknown>,
|
||||
Forward extends SerializedProjectCommand<unknown>,
|
||||
Inverse extends SerializedProjectCommand<unknown>,
|
||||
>(
|
||||
database: AppDatabase,
|
||||
input: ProjectCommandTransactionInput<Command>,
|
||||
applyCommand: (
|
||||
transaction: AppDatabase
|
||||
) => AppliedProjectCommand<Forward, Inverse>
|
||||
): { revision: AppendedProjectRevision; inverse: Inverse } {
|
||||
return database.transaction((tx) => {
|
||||
const { forward, inverse } = applyCommand(tx);
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward,
|
||||
inverse,
|
||||
});
|
||||
applyProjectHistoryTransition(tx, {
|
||||
projectId: input.projectId,
|
||||
source: input.source,
|
||||
recordedChangeSetId: revision.changeSetId,
|
||||
targetChangeSetId: input.historyTargetChangeSetId,
|
||||
});
|
||||
return { revision, inverse };
|
||||
});
|
||||
}
|
||||
@@ -12,8 +12,7 @@ import type {
|
||||
} from "../../domain/ports/project-device-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
type ProjectDeviceRow = typeof projectDevices.$inferSelect;
|
||||
|
||||
@@ -25,76 +24,70 @@ export class ProjectDeviceProjectCommandRepository
|
||||
executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) {
|
||||
assertProjectDeviceUpdateProjectCommand(input.command);
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(projectDevices)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
projectDevices.id,
|
||||
input.command.payload.projectDeviceId
|
||||
),
|
||||
eq(projectDevices.projectId, input.projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error(
|
||||
"Project device does not belong to project."
|
||||
);
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
const patch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
change.value,
|
||||
])
|
||||
) as ProjectDeviceUpdatePatch;
|
||||
const inversePatch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getProjectDeviceFieldValue(current, change.field),
|
||||
])
|
||||
) as ProjectDeviceUpdatePatch;
|
||||
const inverse = createProjectDeviceUpdateProjectCommand(
|
||||
current.id,
|
||||
inversePatch
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteProjectDeviceUpdateCommandInput
|
||||
) {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(projectDevices)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
projectDevices.id,
|
||||
input.command.payload.projectDeviceId
|
||||
),
|
||||
eq(projectDevices.projectId, input.projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error(
|
||||
"Project device does not belong to project."
|
||||
);
|
||||
}
|
||||
|
||||
const updated = tx
|
||||
.update(projectDevices)
|
||||
.set(patch)
|
||||
.where(
|
||||
and(
|
||||
eq(projectDevices.id, current.id),
|
||||
eq(projectDevices.projectId, input.projectId)
|
||||
)
|
||||
const patch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
change.value,
|
||||
])
|
||||
) as ProjectDeviceUpdatePatch;
|
||||
const inversePatch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getProjectDeviceFieldValue(current, change.field),
|
||||
])
|
||||
) as ProjectDeviceUpdatePatch;
|
||||
const inverse = createProjectDeviceUpdateProjectCommand(
|
||||
current.id,
|
||||
inversePatch
|
||||
);
|
||||
|
||||
const updated = tx
|
||||
.update(projectDevices)
|
||||
.set(patch)
|
||||
.where(
|
||||
and(
|
||||
eq(projectDevices.id, current.id),
|
||||
eq(projectDevices.projectId, input.projectId)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Project device changed before command execution."
|
||||
);
|
||||
}
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Project device changed before command execution."
|
||||
);
|
||||
}
|
||||
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward: input.command,
|
||||
inverse,
|
||||
});
|
||||
applyProjectHistoryTransition(tx, {
|
||||
projectId: input.projectId,
|
||||
source: input.source,
|
||||
recordedChangeSetId: revision.changeSetId,
|
||||
targetChangeSetId: input.historyTargetChangeSetId,
|
||||
});
|
||||
return { revision, inverse };
|
||||
});
|
||||
return inverse;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||
|
||||
@@ -28,116 +27,110 @@ export class ProjectDeviceRowSyncProjectCommandRepository
|
||||
execute(input: ExecuteProjectDeviceRowSyncCommandInput) {
|
||||
assertProjectDeviceRowSyncProjectCommand(input.command);
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const projectDevice = tx
|
||||
.select({ id: projectDevices.id })
|
||||
.from(projectDevices)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
projectDevices.id,
|
||||
input.command.payload.projectDeviceId
|
||||
),
|
||||
eq(projectDevices.projectId, input.projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!projectDevice) {
|
||||
throw new Error(
|
||||
"Project device does not belong to project."
|
||||
);
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
const rowIds = input.command.payload.rows.map(
|
||||
(row) => row.rowId
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteProjectDeviceRowSyncCommandInput
|
||||
) {
|
||||
const projectDevice = tx
|
||||
.select({ id: projectDevices.id })
|
||||
.from(projectDevices)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
projectDevices.id,
|
||||
input.command.payload.projectDeviceId
|
||||
),
|
||||
eq(projectDevices.projectId, input.projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!projectDevice) {
|
||||
throw new Error(
|
||||
"Project device does not belong to project."
|
||||
);
|
||||
const persistedRows = tx
|
||||
.select({
|
||||
row: circuitDeviceRows,
|
||||
projectId: circuitLists.projectId,
|
||||
})
|
||||
.from(circuitDeviceRows)
|
||||
.innerJoin(
|
||||
circuits,
|
||||
eq(circuits.id, circuitDeviceRows.circuitId)
|
||||
)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, circuits.circuitListId)
|
||||
)
|
||||
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||
.all();
|
||||
}
|
||||
|
||||
const rowIds = input.command.payload.rows.map(
|
||||
(row) => row.rowId
|
||||
);
|
||||
const persistedRows = tx
|
||||
.select({
|
||||
row: circuitDeviceRows,
|
||||
projectId: circuitLists.projectId,
|
||||
})
|
||||
.from(circuitDeviceRows)
|
||||
.innerJoin(
|
||||
circuits,
|
||||
eq(circuits.id, circuitDeviceRows.circuitId)
|
||||
)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, circuits.circuitListId)
|
||||
)
|
||||
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||
.all();
|
||||
if (
|
||||
persistedRows.length !== rowIds.length ||
|
||||
persistedRows.some(
|
||||
(persisted) => persisted.projectId !== input.projectId
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"One or more sync rows do not belong to project."
|
||||
);
|
||||
}
|
||||
|
||||
const persistedById = new Map(
|
||||
persistedRows.map((persisted) => [
|
||||
persisted.row.id,
|
||||
persisted.row,
|
||||
])
|
||||
);
|
||||
for (const assignment of input.command.payload.rows) {
|
||||
const persisted = persistedById.get(assignment.rowId);
|
||||
if (
|
||||
persistedRows.length !== rowIds.length ||
|
||||
persistedRows.some(
|
||||
(persisted) => persisted.projectId !== input.projectId
|
||||
)
|
||||
!persisted ||
|
||||
!snapshotMatchesRow(assignment.expected, persisted)
|
||||
) {
|
||||
throw new Error(
|
||||
"One or more sync rows do not belong to project."
|
||||
"Project-device row changed before sync execution."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const persistedById = new Map(
|
||||
persistedRows.map((persisted) => [
|
||||
persisted.row.id,
|
||||
persisted.row,
|
||||
])
|
||||
);
|
||||
for (const assignment of input.command.payload.rows) {
|
||||
const persisted = persistedById.get(assignment.rowId);
|
||||
if (
|
||||
!persisted ||
|
||||
!snapshotMatchesRow(assignment.expected, persisted)
|
||||
) {
|
||||
throw new Error(
|
||||
"Project-device row changed before sync execution."
|
||||
);
|
||||
}
|
||||
const inverse = createProjectDeviceRowSyncProjectCommand(
|
||||
projectDevice.id,
|
||||
invertProjectDeviceRowSyncOperation(
|
||||
input.command.payload.operation
|
||||
),
|
||||
input.command.payload.rows.map((row) => ({
|
||||
rowId: row.rowId,
|
||||
expected: row.target,
|
||||
target: row.expected,
|
||||
}))
|
||||
);
|
||||
|
||||
for (const assignment of input.command.payload.rows) {
|
||||
const updated = tx
|
||||
.update(circuitDeviceRows)
|
||||
.set(assignment.target)
|
||||
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Project-device row changed during sync execution."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const inverse = createProjectDeviceRowSyncProjectCommand(
|
||||
projectDevice.id,
|
||||
invertProjectDeviceRowSyncOperation(
|
||||
input.command.payload.operation
|
||||
),
|
||||
input.command.payload.rows.map((row) => ({
|
||||
rowId: row.rowId,
|
||||
expected: row.target,
|
||||
target: row.expected,
|
||||
}))
|
||||
);
|
||||
|
||||
for (const assignment of input.command.payload.rows) {
|
||||
const updated = tx
|
||||
.update(circuitDeviceRows)
|
||||
.set(assignment.target)
|
||||
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Project-device row changed during sync execution."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward: input.command,
|
||||
inverse,
|
||||
});
|
||||
applyProjectHistoryTransition(tx, {
|
||||
projectId: input.projectId,
|
||||
source: input.source,
|
||||
recordedChangeSetId: revision.changeSetId,
|
||||
targetChangeSetId: input.historyTargetChangeSetId,
|
||||
});
|
||||
return { revision, inverse };
|
||||
});
|
||||
return inverse;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,7 @@ import { circuits } from "../schema/circuits.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
const circuitDeviceRowSnapshotFields = [
|
||||
"id",
|
||||
@@ -60,30 +59,17 @@ export class ProjectDeviceStructureProjectCommandRepository
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteProjectDeviceStructureCommandInput) {
|
||||
return this.database.transaction((tx) => {
|
||||
const inverse = this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
input.source,
|
||||
input.command
|
||||
);
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward: input.command,
|
||||
inverse,
|
||||
});
|
||||
applyProjectHistoryTransition(tx, {
|
||||
projectId: input.projectId,
|
||||
source: input.source,
|
||||
recordedChangeSetId: revision.changeSetId,
|
||||
targetChangeSetId: input.historyTargetChangeSetId,
|
||||
});
|
||||
return { revision, inverse };
|
||||
});
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
input.source,
|
||||
input.command
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
|
||||
|
||||
@@ -11,12 +11,16 @@ function withTotalPower(row: typeof projectDevices.$inferSelect) {
|
||||
}
|
||||
|
||||
export class ProjectDeviceRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByProject(projectId: string) {
|
||||
const db = this.database;
|
||||
const rows = await db.select().from(projectDevices).where(eq(projectDevices.projectId, projectId));
|
||||
return rows.map(withTotalPower);
|
||||
}
|
||||
|
||||
async findById(projectId: string, projectDeviceId: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(projectDevices)
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertProjectFloorDeleteProjectCommand,
|
||||
assertProjectFloorInsertProjectCommand,
|
||||
assertProjectRoomDeleteProjectCommand,
|
||||
assertProjectRoomInsertProjectCommand,
|
||||
createProjectFloorDeleteProjectCommand,
|
||||
createProjectFloorInsertProjectCommand,
|
||||
createProjectRoomDeleteProjectCommand,
|
||||
createProjectRoomInsertProjectCommand,
|
||||
projectFloorDeleteCommandType,
|
||||
projectFloorInsertCommandType,
|
||||
projectRoomDeleteCommandType,
|
||||
projectRoomInsertCommandType,
|
||||
type ProjectFloorSnapshot,
|
||||
type ProjectLocationStructureProjectCommand,
|
||||
type ProjectRoomSnapshot,
|
||||
} from "../../domain/models/project-location-structure-project-command.model.js";
|
||||
import type {
|
||||
ExecuteProjectLocationStructureCommandInput,
|
||||
ProjectLocationStructureProjectCommandStore,
|
||||
} from "../../domain/ports/project-location-structure-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { consumers } from "../schema/consumers.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class ProjectLocationStructureProjectCommandRepository
|
||||
implements ProjectLocationStructureProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteProjectLocationStructureCommandInput) {
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: ProjectLocationStructureProjectCommand
|
||||
): ProjectLocationStructureProjectCommand {
|
||||
switch (command.type) {
|
||||
case projectFloorInsertCommandType:
|
||||
assertProjectFloorInsertProjectCommand(command);
|
||||
return this.insertFloor(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.floor
|
||||
);
|
||||
case projectFloorDeleteCommandType:
|
||||
assertProjectFloorDeleteProjectCommand(command);
|
||||
return this.deleteFloor(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.floor
|
||||
);
|
||||
case projectRoomInsertCommandType:
|
||||
assertProjectRoomInsertProjectCommand(command);
|
||||
return this.insertRoom(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.room
|
||||
);
|
||||
case projectRoomDeleteCommandType:
|
||||
assertProjectRoomDeleteProjectCommand(command);
|
||||
return this.deleteRoom(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.room
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private insertFloor(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
floor: ProjectFloorSnapshot
|
||||
) {
|
||||
this.assertProjectExists(database, projectId);
|
||||
if (floor.projectId !== projectId) {
|
||||
throw new Error("Project-floor snapshot does not belong to project.");
|
||||
}
|
||||
const existing = database
|
||||
.select({ id: floors.id })
|
||||
.from(floors)
|
||||
.where(eq(floors.id, floor.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Project-floor id already exists.");
|
||||
}
|
||||
database.insert(floors).values(floor).run();
|
||||
return createProjectFloorDeleteProjectCommand(floor);
|
||||
}
|
||||
|
||||
private deleteFloor(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: ProjectFloorSnapshot
|
||||
) {
|
||||
const persisted = database
|
||||
.select()
|
||||
.from(floors)
|
||||
.where(
|
||||
and(
|
||||
eq(floors.id, expected.id),
|
||||
eq(floors.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!persisted || !sameRecord(expected, persisted)) {
|
||||
throw new Error("Project floor changed before deletion.");
|
||||
}
|
||||
const referencedRoom = database
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(eq(rooms.floorId, expected.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (referencedRoom) {
|
||||
throw new Error(
|
||||
"A project floor with assigned rooms cannot be removed by history."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(floors)
|
||||
.where(
|
||||
and(
|
||||
eq(floors.id, expected.id),
|
||||
eq(floors.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Project floor could not be deleted.");
|
||||
}
|
||||
return createProjectFloorInsertProjectCommand(expected);
|
||||
}
|
||||
|
||||
private insertRoom(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
room: ProjectRoomSnapshot
|
||||
) {
|
||||
this.assertProjectExists(database, projectId);
|
||||
if (room.projectId !== projectId) {
|
||||
throw new Error("Project-room snapshot does not belong to project.");
|
||||
}
|
||||
if (room.floorId) {
|
||||
const floor = database
|
||||
.select({ id: floors.id })
|
||||
.from(floors)
|
||||
.where(
|
||||
and(
|
||||
eq(floors.id, room.floorId),
|
||||
eq(floors.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!floor) {
|
||||
throw new Error("Project room references a foreign floor.");
|
||||
}
|
||||
}
|
||||
const existing = database
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(eq(rooms.id, room.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Project-room id already exists.");
|
||||
}
|
||||
database.insert(rooms).values(room).run();
|
||||
return createProjectRoomDeleteProjectCommand(room);
|
||||
}
|
||||
|
||||
private deleteRoom(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: ProjectRoomSnapshot
|
||||
) {
|
||||
const persisted = database
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.id, expected.id),
|
||||
eq(rooms.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!persisted || !sameRecord(expected, persisted)) {
|
||||
throw new Error("Project room changed before deletion.");
|
||||
}
|
||||
const referencedDeviceRow = database
|
||||
.select({ id: circuitDeviceRows.id })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.roomId, expected.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
const referencedLegacyConsumer = database
|
||||
.select({ id: consumers.id })
|
||||
.from(consumers)
|
||||
.where(eq(consumers.roomId, expected.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (referencedDeviceRow || referencedLegacyConsumer) {
|
||||
throw new Error(
|
||||
"A referenced project room cannot be removed by history."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(rooms)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.id, expected.id),
|
||||
eq(rooms.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Project room could not be deleted.");
|
||||
}
|
||||
return createProjectRoomInsertProjectCommand(expected);
|
||||
}
|
||||
|
||||
private assertProjectExists(
|
||||
database: AppDatabase,
|
||||
projectId: string
|
||||
) {
|
||||
const project = database
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
throw new Error("Project does not exist.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameRecord(
|
||||
expected: object,
|
||||
actual: object
|
||||
) {
|
||||
const expectedEntries = Object.entries(expected);
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
return (
|
||||
expectedEntries.length === Object.keys(actual).length &&
|
||||
expectedEntries.every(([key, value]) => actualRecord[key] === value)
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@ import type {
|
||||
} from "../../domain/ports/project-settings-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class ProjectSettingsProjectCommandRepository
|
||||
implements ProjectSettingsProjectCommandStore
|
||||
@@ -19,53 +18,47 @@ export class ProjectSettingsProjectCommandRepository
|
||||
|
||||
executeUpdate(input: ExecuteProjectSettingsUpdateCommandInput) {
|
||||
assertProjectSettingsUpdateProjectCommand(input.command);
|
||||
return this.database.transaction((tx) => {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
if (
|
||||
current.singlePhaseVoltageV ===
|
||||
input.command.payload.singlePhaseVoltageV &&
|
||||
current.threePhaseVoltageV ===
|
||||
input.command.payload.threePhaseVoltageV
|
||||
) {
|
||||
throw new Error("Project settings did not change.");
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
const inverse = createProjectSettingsUpdateProjectCommand({
|
||||
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||
});
|
||||
const updated = tx
|
||||
.update(projects)
|
||||
.set(input.command.payload)
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Project changed before settings update.");
|
||||
}
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteProjectSettingsUpdateCommandInput
|
||||
) {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
if (
|
||||
current.singlePhaseVoltageV ===
|
||||
input.command.payload.singlePhaseVoltageV &&
|
||||
current.threePhaseVoltageV ===
|
||||
input.command.payload.threePhaseVoltageV
|
||||
) {
|
||||
throw new Error("Project settings did not change.");
|
||||
}
|
||||
|
||||
const 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 };
|
||||
const inverse = createProjectSettingsUpdateProjectCommand({
|
||||
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||
});
|
||||
const updated = tx
|
||||
.update(projects)
|
||||
.set(input.command.payload)
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Project changed before settings update.");
|
||||
}
|
||||
|
||||
return inverse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,7 @@ import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migrat
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import {
|
||||
hashProjectStatePayload,
|
||||
readProjectStateSnapshot,
|
||||
@@ -43,49 +42,43 @@ export class ProjectStateRestoreCommandRepository
|
||||
throw new Error("Restore state belongs to a different project.");
|
||||
}
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const current = readProjectStateSnapshot(tx, input.projectId);
|
||||
if (!current) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
if (
|
||||
current.payloadSha256 !==
|
||||
input.command.payload.expectedStateSha256
|
||||
) {
|
||||
throw new ProjectStateConflictError(
|
||||
input.projectId,
|
||||
input.command.payload.expectedStateSha256,
|
||||
current.payloadSha256
|
||||
);
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
const targetPayloadJson = serializeProjectStateSnapshot(
|
||||
input.command.payload.targetState
|
||||
);
|
||||
const inverse = createProjectStateRestoreCommand(
|
||||
hashProjectStatePayload(targetPayloadJson),
|
||||
current.state
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteProjectStateRestoreCommandInput
|
||||
) {
|
||||
const current = readProjectStateSnapshot(tx, input.projectId);
|
||||
if (!current) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
if (
|
||||
current.payloadSha256 !==
|
||||
input.command.payload.expectedStateSha256
|
||||
) {
|
||||
throw new ProjectStateConflictError(
|
||||
input.projectId,
|
||||
input.command.payload.expectedStateSha256,
|
||||
current.payloadSha256
|
||||
);
|
||||
}
|
||||
|
||||
replaceProjectState(tx, input.command.payload.targetState);
|
||||
const targetPayloadJson = serializeProjectStateSnapshot(
|
||||
input.command.payload.targetState
|
||||
);
|
||||
const inverse = createProjectStateRestoreCommand(
|
||||
hashProjectStatePayload(targetPayloadJson),
|
||||
current.state
|
||||
);
|
||||
|
||||
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 };
|
||||
});
|
||||
replaceProjectState(tx, input.command.payload.targetState);
|
||||
|
||||
return inverse;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import type {
|
||||
CreateProjectInput,
|
||||
} from "../../shared/validation/project-structure.schemas.js";
|
||||
|
||||
export class ProjectRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async list() {
|
||||
const db = this.database;
|
||||
return db.select().from(projects);
|
||||
}
|
||||
|
||||
async create(input: CreateProjectInput) {
|
||||
const db = this.database;
|
||||
const id = crypto.randomUUID();
|
||||
const project = {
|
||||
id,
|
||||
@@ -25,11 +29,8 @@ export class ProjectRepository {
|
||||
}
|
||||
|
||||
async findById(projectId: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async delete(projectId: string) {
|
||||
await db.delete(projects).where(eq(projects.id, projectId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import type { CreateRoomInput } from "../../shared/validation/project-structure.schemas.js";
|
||||
|
||||
export class RoomRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByProject(projectId: string) {
|
||||
const db = this.database;
|
||||
return db
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(eq(rooms.projectId, projectId))
|
||||
.orderBy(asc(rooms.roomNumber), asc(rooms.roomName));
|
||||
}
|
||||
|
||||
async create(projectId: string, input: CreateRoomInput) {
|
||||
const id = crypto.randomUUID();
|
||||
const room = {
|
||||
id,
|
||||
projectId,
|
||||
floorId: input.floorId ?? null,
|
||||
roomNumber: input.roomNumber,
|
||||
roomName: input.roomName,
|
||||
};
|
||||
await db.insert(rooms).values(room);
|
||||
return room;
|
||||
}
|
||||
|
||||
async existsInProject(projectId: string, roomId: string) {
|
||||
const [row] = await db
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(and(eq(rooms.projectId, projectId), eq(rooms.id, roomId)))
|
||||
.limit(1);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
async findById(roomId: string) {
|
||||
const [row] = await db.select().from(rooms).where(eq(rooms.id, roomId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const projectFloorInsertCommandType = "project-floor.insert" as const;
|
||||
export const projectFloorDeleteCommandType = "project-floor.delete" as const;
|
||||
export const projectRoomInsertCommandType = "project-room.insert" as const;
|
||||
export const projectRoomDeleteCommandType = "project-room.delete" as const;
|
||||
export const projectLocationStructureCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ProjectFloorSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ProjectRoomSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
floorId: string | null;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
interface ProjectFloorStructureCommandPayload {
|
||||
floor: ProjectFloorSnapshot;
|
||||
}
|
||||
|
||||
interface ProjectRoomStructureCommandPayload {
|
||||
room: ProjectRoomSnapshot;
|
||||
}
|
||||
|
||||
export interface ProjectFloorInsertProjectCommand
|
||||
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectFloorInsertCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectFloorDeleteProjectCommand
|
||||
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectFloorDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectRoomInsertProjectCommand
|
||||
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectRoomInsertCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectRoomDeleteProjectCommand
|
||||
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectRoomDeleteCommandType;
|
||||
}
|
||||
|
||||
export type ProjectLocationStructureProjectCommand =
|
||||
| ProjectFloorInsertProjectCommand
|
||||
| ProjectFloorDeleteProjectCommand
|
||||
| ProjectRoomInsertProjectCommand
|
||||
| ProjectRoomDeleteProjectCommand;
|
||||
|
||||
export function createProjectFloorSnapshot(
|
||||
projectId: string,
|
||||
name: string,
|
||||
sortOrder: number
|
||||
): ProjectFloorSnapshot {
|
||||
const floor: ProjectFloorSnapshot = {
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
name: name.trim(),
|
||||
sortOrder,
|
||||
};
|
||||
assertProjectFloorSnapshot(floor);
|
||||
return floor;
|
||||
}
|
||||
|
||||
export function createProjectRoomSnapshot(
|
||||
projectId: string,
|
||||
input: {
|
||||
floorId?: string;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
}
|
||||
): ProjectRoomSnapshot {
|
||||
const room: ProjectRoomSnapshot = {
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
floorId: input.floorId?.trim() || null,
|
||||
roomNumber: input.roomNumber.trim(),
|
||||
roomName: input.roomName.trim(),
|
||||
};
|
||||
assertProjectRoomSnapshot(room);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function createProjectFloorInsertProjectCommand(
|
||||
floor: ProjectFloorSnapshot
|
||||
): ProjectFloorInsertProjectCommand {
|
||||
assertProjectFloorSnapshot(floor);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
type: projectFloorInsertCommandType,
|
||||
payload: { floor },
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectFloorDeleteProjectCommand(
|
||||
floor: ProjectFloorSnapshot
|
||||
): ProjectFloorDeleteProjectCommand {
|
||||
assertProjectFloorSnapshot(floor);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
type: projectFloorDeleteCommandType,
|
||||
payload: { floor },
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectRoomInsertProjectCommand(
|
||||
room: ProjectRoomSnapshot
|
||||
): ProjectRoomInsertProjectCommand {
|
||||
assertProjectRoomSnapshot(room);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
type: projectRoomInsertCommandType,
|
||||
payload: { room },
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectRoomDeleteProjectCommand(
|
||||
room: ProjectRoomSnapshot
|
||||
): ProjectRoomDeleteProjectCommand {
|
||||
assertProjectRoomSnapshot(room);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
type: projectRoomDeleteCommandType,
|
||||
payload: { room },
|
||||
};
|
||||
}
|
||||
|
||||
export function assertProjectFloorInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectFloorInsertProjectCommand {
|
||||
assertProjectFloorStructureProjectCommand(
|
||||
command,
|
||||
projectFloorInsertCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertProjectFloorDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectFloorDeleteProjectCommand {
|
||||
assertProjectFloorStructureProjectCommand(
|
||||
command,
|
||||
projectFloorDeleteCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertProjectRoomInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectRoomInsertProjectCommand {
|
||||
assertProjectRoomStructureProjectCommand(
|
||||
command,
|
||||
projectRoomInsertCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertProjectRoomDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectRoomDeleteProjectCommand {
|
||||
assertProjectRoomStructureProjectCommand(
|
||||
command,
|
||||
projectRoomDeleteCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertProjectFloorSnapshot(
|
||||
floor: unknown
|
||||
): asserts floor is ProjectFloorSnapshot {
|
||||
if (!isPlainObject(floor) || Object.keys(floor).length !== 4) {
|
||||
throw new Error("Project-floor snapshot is invalid.");
|
||||
}
|
||||
assertNormalizedNonEmptyString(floor.id, "floor.id");
|
||||
assertNormalizedNonEmptyString(floor.projectId, "floor.projectId");
|
||||
assertNormalizedNonEmptyString(floor.name, "floor.name");
|
||||
if (
|
||||
typeof floor.sortOrder !== "number" ||
|
||||
!Number.isSafeInteger(floor.sortOrder) ||
|
||||
floor.sortOrder < 0
|
||||
) {
|
||||
throw new Error("floor.sortOrder must be a non-negative integer.");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertProjectRoomSnapshot(
|
||||
room: unknown
|
||||
): asserts room is ProjectRoomSnapshot {
|
||||
if (!isPlainObject(room) || Object.keys(room).length !== 5) {
|
||||
throw new Error("Project-room snapshot is invalid.");
|
||||
}
|
||||
assertNormalizedNonEmptyString(room.id, "room.id");
|
||||
assertNormalizedNonEmptyString(room.projectId, "room.projectId");
|
||||
if (room.floorId !== null) {
|
||||
assertNormalizedNonEmptyString(room.floorId, "room.floorId");
|
||||
}
|
||||
assertNormalizedNonEmptyString(room.roomNumber, "room.roomNumber");
|
||||
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
|
||||
}
|
||||
|
||||
function assertProjectFloorStructureProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
expectedType:
|
||||
| typeof projectFloorInsertCommandType
|
||||
| typeof projectFloorDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||
command.type !== expectedType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported project-floor structure command.");
|
||||
}
|
||||
assertProjectFloorSnapshot(command.payload.floor);
|
||||
}
|
||||
|
||||
function assertProjectRoomStructureProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
expectedType:
|
||||
| typeof projectRoomInsertCommandType
|
||||
| typeof projectRoomDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||
command.type !== expectedType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported project-room structure command.");
|
||||
}
|
||||
assertProjectRoomSnapshot(command.payload.room);
|
||||
}
|
||||
|
||||
function assertNormalizedNonEmptyString(
|
||||
value: unknown,
|
||||
field: string
|
||||
): asserts value is string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!value ||
|
||||
value !== value.trim()
|
||||
) {
|
||||
throw new Error(`${field} must be a normalized non-empty string.`);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
export interface LegacyConsumerMigrationCircuitListReader {
|
||||
findById(
|
||||
projectId: string,
|
||||
circuitListId: string
|
||||
): Promise<{ id: string } | null>;
|
||||
}
|
||||
|
||||
export interface LegacyConsumerMigrationSection {
|
||||
id: string;
|
||||
key: string;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export interface LegacyConsumerMigrationSectionStore {
|
||||
createDefaults(circuitListId: string): Promise<void>;
|
||||
listByCircuitList(
|
||||
circuitListId: string
|
||||
): Promise<LegacyConsumerMigrationSection[]>;
|
||||
}
|
||||
|
||||
export interface LegacyConsumerMigrationCircuitReader {
|
||||
listByCircuitList(circuitListId: string): Promise<
|
||||
Array<{
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
sortOrder: number;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface LegacyConsumerMigrationRoomReader {
|
||||
listByProject(projectId: string): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface LegacyConsumerSource {
|
||||
id: string;
|
||||
projectDeviceId: string | null;
|
||||
roomId: string | null;
|
||||
circuitNumber: string | null;
|
||||
description: string | null;
|
||||
name: string;
|
||||
category: string | null;
|
||||
deviceType: string | null;
|
||||
phaseType: string | null;
|
||||
tradeOrCostGroup: string | null;
|
||||
protectionType: string | null;
|
||||
protectionRatedCurrent: number | null;
|
||||
protectionCharacteristic: string | null;
|
||||
cableType: string | null;
|
||||
cableCrossSection: string | null;
|
||||
comment: string | null;
|
||||
quantity: number;
|
||||
installedPowerPerUnitKw: number;
|
||||
demandFactor: number;
|
||||
voltageV: number | null;
|
||||
phaseCount: number | null;
|
||||
powerFactor: number | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface LegacyMigrationDeviceRowInput {
|
||||
linkedProjectDeviceId?: string;
|
||||
legacyConsumerId: string;
|
||||
sortOrder: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
phaseType?: string;
|
||||
connectionKind?: string;
|
||||
costGroup?: string;
|
||||
category?: string;
|
||||
level?: string;
|
||||
roomId?: string;
|
||||
roomNumberSnapshot?: string;
|
||||
roomNameSnapshot?: string;
|
||||
quantity: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi?: number;
|
||||
remark?: string;
|
||||
overriddenFields?: string;
|
||||
}
|
||||
|
||||
export interface LegacyMigrationCircuitInput {
|
||||
circuit: {
|
||||
circuitListId: string;
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
displayName?: string;
|
||||
sortOrder: number;
|
||||
protectionType?: string;
|
||||
protectionRatedCurrent?: number;
|
||||
protectionCharacteristic?: string;
|
||||
cableType?: string;
|
||||
cableCrossSection?: string;
|
||||
cableLength?: number;
|
||||
voltage?: number;
|
||||
controlRequirement?: string;
|
||||
remark?: string;
|
||||
rcdAssignment?: string;
|
||||
terminalDesignation?: string;
|
||||
status?: string;
|
||||
isReserve?: boolean;
|
||||
};
|
||||
deviceRows: LegacyMigrationDeviceRowInput[];
|
||||
}
|
||||
|
||||
export interface LegacyMigrationReportInput {
|
||||
legacyConsumerCount: number;
|
||||
createdCircuitCount: number;
|
||||
createdDeviceRowCount: number;
|
||||
duplicateGroupedCount: number;
|
||||
generatedIdentifierCount: number;
|
||||
unassignedRowCount: number;
|
||||
warningsJson: string;
|
||||
generatedIdentifiersJson: string;
|
||||
duplicateGroupsJson: string;
|
||||
}
|
||||
|
||||
export interface LegacyConsumerMigrationStore {
|
||||
listSourceConsumersByCircuitList(
|
||||
circuitListId: string
|
||||
): Promise<LegacyConsumerSource[]>;
|
||||
listMigratedConsumerIds(circuitListId: string): Promise<string[]>;
|
||||
persistCircuitListMigration(input: {
|
||||
circuitListId: string;
|
||||
circuits: LegacyMigrationCircuitInput[];
|
||||
report: LegacyMigrationReportInput;
|
||||
}): void;
|
||||
}
|
||||
|
||||
export interface LegacyConsumerMigrationDependencies {
|
||||
circuitListReader: LegacyConsumerMigrationCircuitListReader;
|
||||
sectionStore: LegacyConsumerMigrationSectionStore;
|
||||
circuitReader: LegacyConsumerMigrationCircuitReader;
|
||||
roomReader: LegacyConsumerMigrationRoomReader;
|
||||
migrationStore: LegacyConsumerMigrationStore;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ProjectLocationStructureProjectCommand } from "../models/project-location-structure-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteProjectLocationStructureCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: ProjectLocationStructureProjectCommand;
|
||||
}
|
||||
|
||||
export interface ExecutedProjectLocationStructureCommand {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: ProjectLocationStructureProjectCommand;
|
||||
}
|
||||
|
||||
export interface ProjectLocationStructureProjectCommandStore {
|
||||
execute(
|
||||
input: ExecuteProjectLocationStructureCommandInput
|
||||
): ExecutedProjectLocationStructureCommand;
|
||||
}
|
||||
@@ -24,8 +24,3 @@ export interface AppendedProjectRevision {
|
||||
revisionNumber: number;
|
||||
createdAtIso: string;
|
||||
}
|
||||
|
||||
export interface ProjectRevisionStore {
|
||||
getCurrentRevision(projectId: string): number | null;
|
||||
append(input: AppendProjectRevisionInput): AppendedProjectRevision;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||
export interface CircuitNumberingSectionReader {
|
||||
findById(
|
||||
sectionId: string
|
||||
): Promise<{ prefix: string } | null>;
|
||||
}
|
||||
|
||||
export interface CircuitNumberingCircuitReader {
|
||||
listBySection(
|
||||
sectionId: string
|
||||
): Promise<Array<{ equipmentIdentifier: string }>>;
|
||||
}
|
||||
|
||||
export interface CircuitNumberingDependencies {
|
||||
sectionRepository: CircuitNumberingSectionReader;
|
||||
circuitRepository: CircuitNumberingCircuitReader;
|
||||
}
|
||||
|
||||
function parseSuffix(equipmentIdentifier: string, prefix: string): number | null {
|
||||
if (!equipmentIdentifier.startsWith(prefix)) {
|
||||
@@ -13,15 +27,12 @@ function parseSuffix(equipmentIdentifier: string, prefix: string): number | null
|
||||
}
|
||||
|
||||
export class CircuitNumberingService {
|
||||
private readonly sectionRepository: Pick<CircuitSectionRepository, "findById">;
|
||||
private readonly circuitRepository: Pick<CircuitRepository, "listBySection">;
|
||||
private readonly sectionRepository: CircuitNumberingSectionReader;
|
||||
private readonly circuitRepository: CircuitNumberingCircuitReader;
|
||||
|
||||
constructor(deps?: {
|
||||
sectionRepository?: Pick<CircuitSectionRepository, "findById">;
|
||||
circuitRepository?: Pick<CircuitRepository, "listBySection">;
|
||||
}) {
|
||||
this.sectionRepository = deps?.sectionRepository ?? new CircuitSectionRepository();
|
||||
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
|
||||
constructor(deps: CircuitNumberingDependencies) {
|
||||
this.sectionRepository = deps.sectionRepository;
|
||||
this.circuitRepository = deps.circuitRepository;
|
||||
}
|
||||
|
||||
async getNextIdentifier(sectionId: string) {
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||
import {
|
||||
LegacyConsumerMigrationRepository,
|
||||
type LegacyMigrationCircuitPersistenceInput,
|
||||
} from "../../db/repositories/legacy-consumer-migration.repository.js";
|
||||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||
import type { LegacyMigrationReport } from "../models/circuit-tree.model.js";
|
||||
import type {
|
||||
LegacyConsumerMigrationDependencies,
|
||||
LegacyConsumerSource,
|
||||
LegacyMigrationCircuitInput,
|
||||
} from "../ports/legacy-consumer-migration.store.js";
|
||||
import {
|
||||
inferSectionKeyFromEquipmentIdentifier,
|
||||
inferSectionKeyFromLegacyInput,
|
||||
normalizeCircuitNumber,
|
||||
} from "./legacy-consumer-migration-planner.js";
|
||||
|
||||
type LegacyConsumerRow = Awaited<
|
||||
ReturnType<
|
||||
LegacyConsumerMigrationRepository["listSourceConsumersByCircuitList"]
|
||||
>
|
||||
>[number];
|
||||
|
||||
function parseEquipmentSequence(equipmentIdentifier: string, prefix: string): number | null {
|
||||
function parseEquipmentSequence(
|
||||
equipmentIdentifier: string,
|
||||
prefix: string
|
||||
): number | null {
|
||||
if (!equipmentIdentifier.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
@@ -30,43 +24,45 @@ function parseEquipmentSequence(equipmentIdentifier: string, prefix: string): nu
|
||||
return Number(suffix);
|
||||
}
|
||||
|
||||
|
||||
export class LegacyConsumerMigrationService {
|
||||
private readonly circuitListRepository = new CircuitListRepository();
|
||||
private readonly sectionRepository = new CircuitSectionRepository();
|
||||
private readonly circuitRepository = new CircuitRepository();
|
||||
private readonly roomRepository = new RoomRepository();
|
||||
|
||||
constructor(
|
||||
private readonly migrationRepository: LegacyConsumerMigrationRepository
|
||||
private readonly dependencies: LegacyConsumerMigrationDependencies
|
||||
) {}
|
||||
|
||||
async migrateCircuitList(projectId: string, circuitListId: string): Promise<LegacyMigrationReport> {
|
||||
async migrateCircuitList(
|
||||
projectId: string,
|
||||
circuitListId: string
|
||||
): Promise<LegacyMigrationReport> {
|
||||
// Migration is additive: legacy consumers are preserved, and circuit-first entities are created
|
||||
// with mapping records so transition remains auditable and reversible.
|
||||
const list = await this.circuitListRepository.findById(projectId, circuitListId);
|
||||
const list = await this.dependencies.circuitListReader.findById(
|
||||
projectId,
|
||||
circuitListId
|
||||
);
|
||||
if (!list) {
|
||||
throw new Error("Circuit list not found in project.");
|
||||
}
|
||||
|
||||
await this.sectionRepository.createDefaults(circuitListId);
|
||||
const sections = await this.sectionRepository.listByCircuitList(circuitListId);
|
||||
await this.dependencies.sectionStore.createDefaults(circuitListId);
|
||||
const sections =
|
||||
await this.dependencies.sectionStore.listByCircuitList(circuitListId);
|
||||
const sectionByKey = new Map(sections.map((section) => [section.key, section]));
|
||||
const unassignedSection = sectionByKey.get("unassigned");
|
||||
if (!unassignedSection) {
|
||||
throw new Error("Unassigned section is required.");
|
||||
}
|
||||
|
||||
const existingCircuits = await this.circuitRepository.listByCircuitList(circuitListId);
|
||||
const existingCircuits =
|
||||
await this.dependencies.circuitReader.listByCircuitList(circuitListId);
|
||||
const usedEquipmentIdentifiers = new Set(
|
||||
existingCircuits.map((circuit) => circuit.equipmentIdentifier.toUpperCase())
|
||||
);
|
||||
|
||||
const legacyConsumers =
|
||||
await this.migrationRepository.listSourceConsumersByCircuitList(
|
||||
await this.dependencies.migrationStore.listSourceConsumersByCircuitList(
|
||||
circuitListId
|
||||
);
|
||||
const rooms = await this.roomRepository.listByProject(projectId);
|
||||
const rooms = await this.dependencies.roomReader.listByProject(projectId);
|
||||
const roomById = new Map(rooms.map((room) => [room.id, room]));
|
||||
|
||||
const report: LegacyMigrationReport = {
|
||||
@@ -82,14 +78,16 @@ export class LegacyConsumerMigrationService {
|
||||
|
||||
// Idempotency guard: skip consumers already mapped in previous migration run.
|
||||
const migratedConsumerIds = new Set(
|
||||
await this.migrationRepository.listMigratedConsumerIds(circuitListId)
|
||||
await this.dependencies.migrationStore.listMigratedConsumerIds(
|
||||
circuitListId
|
||||
)
|
||||
);
|
||||
const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id));
|
||||
|
||||
// Legacy rows are grouped by normalized circuit number so duplicates become
|
||||
// multiple device rows within one circuit instead of duplicate circuits.
|
||||
const byNormalizedCircuitNumber = new Map<string, LegacyConsumerRow[]>();
|
||||
const withoutNormalizedCircuitNumber: LegacyConsumerRow[] = [];
|
||||
const byNormalizedCircuitNumber = new Map<string, LegacyConsumerSource[]>();
|
||||
const withoutNormalizedCircuitNumber: LegacyConsumerSource[] = [];
|
||||
for (const consumer of consumersToMigrate) {
|
||||
const normalized = normalizeCircuitNumber(consumer.circuitNumber ?? null);
|
||||
if (!normalized) {
|
||||
@@ -110,7 +108,7 @@ export class LegacyConsumerMigrationService {
|
||||
|
||||
const groups: Array<{
|
||||
equipmentIdentifier: string | null;
|
||||
consumers: LegacyConsumerRow[];
|
||||
consumers: LegacyConsumerSource[];
|
||||
inferredSectionKey: string | null;
|
||||
isGeneratedIdentifier: boolean;
|
||||
}> = [];
|
||||
@@ -152,7 +150,7 @@ export class LegacyConsumerMigrationService {
|
||||
maxBySectionPrefix.set(section.prefix.toUpperCase(), Math.max(current, sequence));
|
||||
}
|
||||
|
||||
const migrationCircuits: LegacyMigrationCircuitPersistenceInput[] = [];
|
||||
const migrationCircuits: LegacyMigrationCircuitInput[] = [];
|
||||
for (const group of groups) {
|
||||
const representative = group.consumers[0];
|
||||
let section = group.inferredSectionKey ? sectionByKey.get(group.inferredSectionKey) : null;
|
||||
@@ -181,7 +179,7 @@ export class LegacyConsumerMigrationService {
|
||||
|
||||
usedEquipmentIdentifiers.add(equipmentIdentifier.toUpperCase());
|
||||
|
||||
const deviceRows: LegacyMigrationCircuitPersistenceInput["deviceRows"] = [];
|
||||
const deviceRows: LegacyMigrationCircuitInput["deviceRows"] = [];
|
||||
const circuitSortOrder = nextSortOrder;
|
||||
nextSortOrder += 10;
|
||||
|
||||
@@ -250,7 +248,7 @@ export class LegacyConsumerMigrationService {
|
||||
report.warnings.push(`Consumer ${row.consumerId} was migrated into unassigned section.`);
|
||||
}
|
||||
|
||||
this.migrationRepository.persistCircuitListMigration({
|
||||
this.dependencies.migrationStore.persistCircuitListMigration({
|
||||
circuitListId,
|
||||
circuits: migrationCircuits,
|
||||
report: {
|
||||
|
||||
@@ -47,6 +47,16 @@ import {
|
||||
assertSerializedProjectCommand,
|
||||
type SerializedProjectCommand,
|
||||
} from "../models/project-command.model.js";
|
||||
import {
|
||||
assertProjectFloorDeleteProjectCommand,
|
||||
assertProjectFloorInsertProjectCommand,
|
||||
assertProjectRoomDeleteProjectCommand,
|
||||
assertProjectRoomInsertProjectCommand,
|
||||
projectFloorDeleteCommandType,
|
||||
projectFloorInsertCommandType,
|
||||
projectRoomDeleteCommandType,
|
||||
projectRoomInsertCommandType,
|
||||
} from "../models/project-location-structure-project-command.model.js";
|
||||
import {
|
||||
assertProjectStateRestoreCommand,
|
||||
projectStateRestoreCommandType,
|
||||
@@ -87,6 +97,7 @@ import type {
|
||||
ProjectHistoryDirection,
|
||||
ProjectHistoryStore,
|
||||
} from "../ports/project-history.store.js";
|
||||
import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js";
|
||||
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
|
||||
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
|
||||
import type { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.store.js";
|
||||
@@ -112,6 +123,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
|
||||
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
|
||||
private readonly distributionBoardStructureStore: DistributionBoardStructureProjectCommandStore,
|
||||
private readonly projectLocationStructureStore: ProjectLocationStructureProjectCommandStore,
|
||||
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
|
||||
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
|
||||
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
|
||||
@@ -273,6 +285,34 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case projectFloorInsertCommandType: {
|
||||
assertProjectFloorInsertProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case projectFloorDeleteCommandType: {
|
||||
assertProjectFloorDeleteProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case projectRoomInsertCommandType: {
|
||||
assertProjectRoomInsertProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case projectRoomDeleteCommandType: {
|
||||
assertProjectRoomDeleteProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitSectionReorderCommandType: {
|
||||
assertCircuitSectionReorderProjectCommand(input.command);
|
||||
return this.circuitSectionReorderStore.execute({
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||
import {
|
||||
createProjectDeviceRowSyncProjectCommand,
|
||||
projectDeviceSyncRowSnapshotFields,
|
||||
@@ -20,15 +18,44 @@ export {
|
||||
serializeOverriddenFields,
|
||||
} from "./project-device-overrides.js";
|
||||
|
||||
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>;
|
||||
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
|
||||
export type ProjectDeviceSyncSource = {
|
||||
id: string;
|
||||
} & Pick<ProjectDeviceSyncRowSnapshot, ProjectDeviceSyncField>;
|
||||
|
||||
type SyncDependencies = {
|
||||
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
|
||||
deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice">;
|
||||
export type LinkedProjectDeviceRow = ProjectDeviceSyncRowSnapshot & {
|
||||
id: string;
|
||||
circuitId: string;
|
||||
equipmentIdentifier: string;
|
||||
circuitDisplayName: string | null;
|
||||
circuitListId: string;
|
||||
circuitListName: string;
|
||||
distributionBoardId: string;
|
||||
distributionBoardName: string;
|
||||
};
|
||||
|
||||
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
|
||||
export interface ProjectDeviceSyncSourceReader {
|
||||
findById(
|
||||
projectId: string,
|
||||
projectDeviceId: string
|
||||
): Promise<ProjectDeviceSyncSource | null>;
|
||||
}
|
||||
|
||||
export interface LinkedProjectDeviceRowReader {
|
||||
listLinkedByProjectDevice(
|
||||
projectId: string,
|
||||
projectDeviceId: string
|
||||
): Promise<LinkedProjectDeviceRow[]>;
|
||||
}
|
||||
|
||||
export interface ProjectDeviceSyncDependencies {
|
||||
projectDeviceRepository: ProjectDeviceSyncSourceReader;
|
||||
deviceRowRepository: LinkedProjectDeviceRowReader;
|
||||
}
|
||||
|
||||
function sourceValue(
|
||||
projectDevice: ProjectDeviceSyncSource,
|
||||
field: ProjectDeviceSyncField
|
||||
) {
|
||||
return projectDevice[field];
|
||||
}
|
||||
|
||||
@@ -36,7 +63,10 @@ function valuesEqual(left: unknown, right: unknown) {
|
||||
return (left ?? null) === (right ?? null);
|
||||
}
|
||||
|
||||
function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
|
||||
function buildDifferences(
|
||||
projectDevice: ProjectDeviceSyncSource,
|
||||
row: LinkedProjectDeviceRow
|
||||
) {
|
||||
return projectDeviceSyncFields
|
||||
.filter((field) => !valuesEqual(row[field], sourceValue(projectDevice, field)))
|
||||
.map((field) => ({
|
||||
@@ -48,12 +78,12 @@ function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
|
||||
}
|
||||
|
||||
export class ProjectDeviceSyncService {
|
||||
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
|
||||
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
|
||||
private readonly projectDeviceRepository: ProjectDeviceSyncSourceReader;
|
||||
private readonly deviceRowRepository: LinkedProjectDeviceRowReader;
|
||||
|
||||
constructor(deps?: Partial<SyncDependencies>) {
|
||||
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
||||
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
||||
constructor(deps: ProjectDeviceSyncDependencies) {
|
||||
this.projectDeviceRepository = deps.projectDeviceRepository;
|
||||
this.deviceRowRepository = deps.deviceRowRepository;
|
||||
}
|
||||
|
||||
async getPreview(projectId: string, projectDeviceId: string) {
|
||||
@@ -154,10 +184,15 @@ export class ProjectDeviceSyncService {
|
||||
);
|
||||
}
|
||||
|
||||
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
|
||||
private resolveSelectedRows(
|
||||
linkedRows: LinkedProjectDeviceRow[],
|
||||
rowIds: string[]
|
||||
) {
|
||||
const uniqueRowIds = [...new Set(rowIds)];
|
||||
const byId = new Map(linkedRows.map((row) => [row.id, row]));
|
||||
const selectedRows = uniqueRowIds.map((rowId) => byId.get(rowId)).filter(Boolean) as LinkedRow[];
|
||||
const selectedRows = uniqueRowIds
|
||||
.map((rowId) => byId.get(rowId))
|
||||
.filter(Boolean) as LinkedProjectDeviceRow[];
|
||||
if (selectedRows.length !== uniqueRowIds.length) {
|
||||
throw new Error("One or more rows are not linked to this project device.");
|
||||
}
|
||||
@@ -166,7 +201,9 @@ export class ProjectDeviceSyncService {
|
||||
|
||||
}
|
||||
|
||||
function toSyncSnapshot(row: LinkedRow): ProjectDeviceSyncRowSnapshot {
|
||||
function toSyncSnapshot(
|
||||
row: LinkedProjectDeviceRow
|
||||
): ProjectDeviceSyncRowSnapshot {
|
||||
return {
|
||||
linkedProjectDeviceId: row.linkedProjectDeviceId,
|
||||
name: row.name,
|
||||
|
||||
@@ -106,6 +106,11 @@ export interface FloorDto {
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ProjectFloorCommandResultDto
|
||||
extends ProjectCommandResultDto {
|
||||
floor: FloorDto;
|
||||
}
|
||||
|
||||
export interface RoomDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -146,6 +151,11 @@ export interface ProjectDeviceDto {
|
||||
voltageV: number | null;
|
||||
}
|
||||
|
||||
export interface ProjectRoomCommandResultDto
|
||||
extends ProjectCommandResultDto {
|
||||
room: RoomDto;
|
||||
}
|
||||
|
||||
export interface ProjectDeviceCommandResultDto
|
||||
extends ProjectCommandResultDto {
|
||||
projectDevice: ProjectDeviceDto;
|
||||
|
||||
+26
-10
@@ -12,11 +12,13 @@ import type {
|
||||
ProjectCommandResultDto,
|
||||
ProjectDeviceCommandResultDto,
|
||||
ProjectDeviceDto,
|
||||
ProjectFloorCommandResultDto,
|
||||
ProjectHistoryStateDto,
|
||||
ProjectRevisionPageDto,
|
||||
ProjectSnapshotMetadataDto,
|
||||
ProjectSnapshotRestoreResultDto,
|
||||
ProjectSettingsCommandResultDto,
|
||||
ProjectRoomCommandResultDto,
|
||||
ProjectDeviceSyncCommandResultDto,
|
||||
ProjectDeviceSyncPreviewDto,
|
||||
ProjectDto,
|
||||
@@ -454,22 +456,36 @@ export function listFloors(projectId: string) {
|
||||
return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
|
||||
}
|
||||
|
||||
export function createFloor(projectId: string, input: CreateFloorInput) {
|
||||
return request<FloorDto>(`/api/projects/${projectId}/floors`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export function createFloor(
|
||||
projectId: string,
|
||||
input: CreateFloorInput,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectFloorCommandResultDto>(
|
||||
`/api/projects/${projectId}/floors`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...input, expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function listRooms(projectId: string) {
|
||||
return request<RoomDto[]>(`/api/projects/${projectId}/rooms`);
|
||||
}
|
||||
|
||||
export function createRoom(projectId: string, input: CreateRoomInput) {
|
||||
return request<RoomDto>(`/api/projects/${projectId}/rooms`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export function createRoom(
|
||||
projectId: string,
|
||||
input: CreateRoomInput,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectRoomCommandResultDto>(
|
||||
`/api/projects/${projectId}/rooms`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...input, expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function listGlobalDevices() {
|
||||
|
||||
@@ -32,6 +32,10 @@ const commandTypeLabels: Record<string, string> = {
|
||||
"project.update-settings": "Projekteinstellungen bearbeitet",
|
||||
"distribution-board.insert": "Verteilung angelegt",
|
||||
"distribution-board.delete": "Verteilung entfernt",
|
||||
"project-floor.insert": "Geschoss angelegt",
|
||||
"project-floor.delete": "Geschoss entfernt",
|
||||
"project-room.insert": "Raum angelegt",
|
||||
"project-room.delete": "Raum entfernt",
|
||||
"project.restore-state": "Projektstand wiederhergestellt",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { db } from "../../db/client.js";
|
||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
||||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||
|
||||
export const circuitDeviceRowRepository =
|
||||
new CircuitDeviceRowRepository(db);
|
||||
export const circuitListRepository = new CircuitListRepository(db);
|
||||
export const circuitSectionRepository = new CircuitSectionRepository(db);
|
||||
export const circuitRepository = new CircuitRepository(db);
|
||||
export const distributionBoardRepository =
|
||||
new DistributionBoardRepository(db);
|
||||
export const floorRepository = new FloorRepository(db);
|
||||
export const globalDeviceRepository = new GlobalDeviceRepository(db);
|
||||
export const projectDeviceRepository = new ProjectDeviceRepository(db);
|
||||
export const projectRepository = new ProjectRepository(db);
|
||||
export const roomRepository = new RoomRepository(db);
|
||||
@@ -1,3 +1,10 @@
|
||||
import { CircuitNumberingService } from "../../domain/services/circuit-numbering.service.js";
|
||||
import {
|
||||
circuitRepository,
|
||||
circuitSectionRepository,
|
||||
} from "./application-repositories.js";
|
||||
|
||||
export const circuitNumberingService = new CircuitNumberingService();
|
||||
export const circuitNumberingService = new CircuitNumberingService({
|
||||
sectionRepository: circuitSectionRepository,
|
||||
circuitRepository,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../../db/reposit
|
||||
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
||||
import { ProjectLocationStructureProjectCommandRepository } from "../../db/repositories/project-location-structure-project-command.repository.js";
|
||||
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
|
||||
import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js";
|
||||
import { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js";
|
||||
@@ -26,6 +27,8 @@ export const circuitStructureProjectCommandStore =
|
||||
new CircuitStructureProjectCommandRepository(db);
|
||||
export const distributionBoardStructureProjectCommandStore =
|
||||
new DistributionBoardStructureProjectCommandRepository(db);
|
||||
export const projectLocationStructureProjectCommandStore =
|
||||
new ProjectLocationStructureProjectCommandRepository(db);
|
||||
export const circuitSectionReorderProjectCommandStore =
|
||||
new CircuitSectionReorderProjectCommandRepository(db);
|
||||
export const circuitSectionRenumberProjectCommandStore =
|
||||
@@ -48,6 +51,7 @@ export const projectCommandService = new ProjectCommandService(
|
||||
circuitDeviceRowMoveProjectCommandStore,
|
||||
circuitStructureProjectCommandStore,
|
||||
distributionBoardStructureProjectCommandStore,
|
||||
projectLocationStructureProjectCommandStore,
|
||||
circuitSectionReorderProjectCommandStore,
|
||||
circuitSectionRenumberProjectCommandStore,
|
||||
projectDeviceStructureProjectCommandStore,
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
|
||||
import {
|
||||
circuitDeviceRowRepository,
|
||||
projectDeviceRepository,
|
||||
} from "./application-repositories.js";
|
||||
|
||||
export const projectDeviceSyncService = new ProjectDeviceSyncService();
|
||||
export const projectDeviceSyncService = new ProjectDeviceSyncService({
|
||||
projectDeviceRepository,
|
||||
deviceRowRepository: circuitDeviceRowRepository,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||
|
||||
const circuitListRepository = new CircuitListRepository();
|
||||
import { circuitListRepository } from "../composition/application-repositories.js";
|
||||
|
||||
export async function listCircuitListsByProject(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
||||
import {
|
||||
calculateCircuitTotalPower,
|
||||
calculateRowTotalPower,
|
||||
} from "../../domain/calculations/circuit-power-calculation.js";
|
||||
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
|
||||
|
||||
const circuitListRepository = new CircuitListRepository();
|
||||
const circuitSectionRepository = new CircuitSectionRepository();
|
||||
const circuitRepository = new CircuitRepository();
|
||||
const circuitDeviceRowRepository = new CircuitDeviceRowRepository();
|
||||
const projectRepository = new ProjectRepository();
|
||||
import {
|
||||
circuitDeviceRowRepository,
|
||||
circuitListRepository,
|
||||
circuitRepository,
|
||||
circuitSectionRepository,
|
||||
projectRepository,
|
||||
} from "../composition/application-repositories.js";
|
||||
|
||||
export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { db } from "../../db/client.js";
|
||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||
import {
|
||||
createDistributionBoardInsertProjectCommand,
|
||||
createDistributionBoardStructureSnapshot,
|
||||
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||
import { createDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js";
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { distributionBoardRepository } from "../composition/application-repositories.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
|
||||
const distributionBoardRepository = new DistributionBoardRepository(db);
|
||||
|
||||
export async function listDistributionBoardsByProject(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (typeof projectId !== "string") {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
||||
import {
|
||||
createProjectFloorInsertProjectCommand,
|
||||
createProjectFloorSnapshot,
|
||||
} from "../../domain/models/project-location-structure-project-command.model.js";
|
||||
import { createFloorSchema } from "../../shared/validation/project-structure.schemas.js";
|
||||
|
||||
const floorRepository = new FloorRepository();
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { floorRepository } from "../composition/application-repositories.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
|
||||
export async function listFloorsByProject(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
@@ -25,6 +29,24 @@ export async function createFloor(req: Request, res: Response) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const floor = await floorRepository.create(projectId, parsed.data.name);
|
||||
return res.status(201).json(floor);
|
||||
const existingFloors = await floorRepository.listByProject(projectId);
|
||||
const nextSortOrder = existingFloors.length
|
||||
? Math.max(...existingFloors.map((floor) => floor.sortOrder)) + 1
|
||||
: 0;
|
||||
const floor = createProjectFloorSnapshot(
|
||||
projectId,
|
||||
parsed.data.name,
|
||||
nextSortOrder
|
||||
);
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision: parsed.data.expectedRevision,
|
||||
description: "Geschoss anlegen",
|
||||
command: createProjectFloorInsertProjectCommand(floor),
|
||||
});
|
||||
return res.status(201).json({ ...result, floor });
|
||||
} catch (error) {
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||
import {
|
||||
createGlobalDeviceSchema,
|
||||
updateGlobalDeviceSchema,
|
||||
} from "../../shared/validation/global-device.schemas.js";
|
||||
|
||||
const globalDeviceRepository = new GlobalDeviceRepository();
|
||||
const projectDeviceRepository = new ProjectDeviceRepository();
|
||||
import {
|
||||
globalDeviceRepository,
|
||||
projectDeviceRepository,
|
||||
} from "../composition/application-repositories.js";
|
||||
|
||||
export async function listGlobalDevices(_req: Request, res: Response) {
|
||||
const rows = await globalDeviceRepository.list();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||
import { createProjectDeviceUpdateProjectCommand } from "../../domain/models/project-device-project-command.model.js";
|
||||
import {
|
||||
createProjectDeviceDeleteProjectCommand,
|
||||
@@ -10,6 +8,10 @@ import {
|
||||
} from "../../domain/models/project-device-structure-project-command.model.js";
|
||||
import { projectDeviceSyncService } from "../composition/project-device-sync-service.js";
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import {
|
||||
globalDeviceRepository,
|
||||
projectDeviceRepository,
|
||||
} from "../composition/application-repositories.js";
|
||||
import {
|
||||
createProjectDeviceCommandSchema,
|
||||
disconnectProjectDeviceRowsSchema,
|
||||
@@ -20,8 +22,6 @@ import {
|
||||
import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
|
||||
const globalDeviceRepository = new GlobalDeviceRepository();
|
||||
const projectDeviceRepository = new ProjectDeviceRepository();
|
||||
export async function listProjectDevicesByProject(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (typeof projectId !== "string") {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
||||
import {
|
||||
createProjectSchema,
|
||||
updateProjectSettingsSchema,
|
||||
} from "../../shared/validation/project-structure.schemas.js";
|
||||
import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js";
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { projectRepository } from "../composition/application-repositories.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
|
||||
const projectRepository = new ProjectRepository();
|
||||
|
||||
export async function listProjects(_req: Request, res: Response) {
|
||||
const result = await projectRepository.list();
|
||||
res.json(result);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
||||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||
import {
|
||||
createProjectRoomInsertProjectCommand,
|
||||
createProjectRoomSnapshot,
|
||||
} from "../../domain/models/project-location-structure-project-command.model.js";
|
||||
import { createRoomSchema } from "../../shared/validation/project-structure.schemas.js";
|
||||
|
||||
const floorRepository = new FloorRepository();
|
||||
const roomRepository = new RoomRepository();
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { roomRepository } from "../composition/application-repositories.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
|
||||
export async function listRoomsByProject(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
@@ -27,13 +29,16 @@ export async function createRoom(req: Request, res: Response) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
if (parsed.data.floorId) {
|
||||
const hasValidFloor = await floorRepository.existsInProject(projectId, parsed.data.floorId);
|
||||
if (!hasValidFloor) {
|
||||
return res.status(400).json({ error: "Floor does not belong to the provided project." });
|
||||
}
|
||||
const room = createProjectRoomSnapshot(projectId, parsed.data);
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision: parsed.data.expectedRevision,
|
||||
description: "Raum anlegen",
|
||||
command: createProjectRoomInsertProjectCommand(room),
|
||||
});
|
||||
return res.status(201).json({ ...result, room });
|
||||
} catch (error) {
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
|
||||
const room = await roomRepository.create(projectId, parsed.data);
|
||||
return res.status(201).json(room);
|
||||
}
|
||||
|
||||
@@ -22,15 +22,21 @@ export const createDistributionBoardSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const createFloorSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
export const createFloorSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
name: z.string().trim().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const createRoomSchema = z.object({
|
||||
floorId: z.string().min(1).optional(),
|
||||
roomNumber: z.string().min(1),
|
||||
roomName: z.string().min(1),
|
||||
});
|
||||
export const createRoomSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
floorId: z.string().trim().min(1).optional(),
|
||||
roomNumber: z.string().trim().min(1),
|
||||
roomName: z.string().trim().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||
export type UpdateProjectSettingsInput = z.infer<
|
||||
@@ -39,5 +45,3 @@ export type UpdateProjectSettingsInput = z.infer<
|
||||
export type CreateDistributionBoardInput = z.infer<
|
||||
typeof createDistributionBoardSchema
|
||||
>;
|
||||
export type CreateFloorInput = z.infer<typeof createFloorSchema>;
|
||||
export type CreateRoomInput = z.infer<typeof createRoomSchema>;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
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 { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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";
|
||||
@@ -39,7 +39,7 @@ function createTestDatabase(): TestFixture {
|
||||
{ id: "project-2", name: "Other project" },
|
||||
])
|
||||
.run();
|
||||
const boards = new DistributionBoardRepository(context.db);
|
||||
const boards = new DistributionBoardFixtureRepository(context.db);
|
||||
const board = boards.createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV-01"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
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 { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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";
|
||||
@@ -34,7 +34,7 @@ function createTestDatabase(): DatabaseContext {
|
||||
])
|
||||
.run();
|
||||
|
||||
const boards = new DistributionBoardRepository(context.db);
|
||||
const boards = new DistributionBoardFixtureRepository(context.db);
|
||||
const board = boards.createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV-01"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
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 { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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";
|
||||
@@ -36,7 +36,7 @@ function createTestDatabase(): DatabaseContext {
|
||||
])
|
||||
.run();
|
||||
|
||||
const boards = new DistributionBoardRepository(context.db);
|
||||
const boards = new DistributionBoardFixtureRepository(context.db);
|
||||
const board = boards.createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV-01"
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("circuit numbering service", () => {
|
||||
const service = new CircuitNumberingService({
|
||||
sectionRepository: {
|
||||
async findById() {
|
||||
return { id: "s1", prefix: "-2F" } as never;
|
||||
return { prefix: "-2F" };
|
||||
},
|
||||
},
|
||||
circuitRepository: {
|
||||
@@ -18,7 +18,7 @@ describe("circuit numbering service", () => {
|
||||
{ equipmentIdentifier: "-2F5" },
|
||||
{ equipmentIdentifier: "-2FX" },
|
||||
{ equipmentIdentifier: "-1F9" },
|
||||
] as never[];
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
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 { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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";
|
||||
@@ -38,7 +38,7 @@ function createTestFixture(): TestFixture {
|
||||
])
|
||||
.run();
|
||||
|
||||
const repository = new DistributionBoardRepository(context.db);
|
||||
const repository = new DistributionBoardFixtureRepository(context.db);
|
||||
const board = repository.createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV-01"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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";
|
||||
@@ -35,7 +35,7 @@ function createTestDatabase(): TestFixture {
|
||||
{ id: "project-2", name: "Other project" },
|
||||
])
|
||||
.run();
|
||||
const boards = new DistributionBoardRepository(context.db);
|
||||
const boards = new DistributionBoardFixtureRepository(context.db);
|
||||
const board = boards.createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV-01"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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";
|
||||
@@ -37,7 +37,7 @@ function createTestDatabase(): TestFixture {
|
||||
{ id: "project-2", name: "Other project" },
|
||||
])
|
||||
.run();
|
||||
const boards = new DistributionBoardRepository(context.db);
|
||||
const boards = new DistributionBoardFixtureRepository(context.db);
|
||||
const board = boards.createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV-01"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
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 { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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";
|
||||
@@ -45,7 +45,7 @@ function createTestDatabase(): TestFixture {
|
||||
])
|
||||
.run();
|
||||
|
||||
const boards = new DistributionBoardRepository(context.db);
|
||||
const boards = new DistributionBoardFixtureRepository(context.db);
|
||||
const board = boards.createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV-01"
|
||||
|
||||
@@ -10,6 +10,15 @@ import {
|
||||
verifyDatabaseFile,
|
||||
} from "../src/db/database-backup.js";
|
||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
|
||||
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
|
||||
import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js";
|
||||
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
|
||||
import { FloorRepository } from "../src/db/repositories/floor.repository.js";
|
||||
import { GlobalDeviceRepository } from "../src/db/repositories/global-device.repository.js";
|
||||
import { ProjectDeviceRepository } from "../src/db/repositories/project-device.repository.js";
|
||||
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
|
||||
import { RoomRepository } from "../src/db/repositories/room.repository.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
|
||||
describe("database backup and restore", () => {
|
||||
@@ -86,3 +95,71 @@ describe("database backup and restore", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("application repository database contexts", () => {
|
||||
it("keep reads and writes inside their injected database", async () => {
|
||||
const first = createDatabaseContext(":memory:");
|
||||
const second = createDatabaseContext(":memory:");
|
||||
try {
|
||||
for (const context of [first, second]) {
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
}
|
||||
|
||||
const firstProjects = new ProjectRepository(first.db);
|
||||
const secondProjects = new ProjectRepository(second.db);
|
||||
const project = await firstProjects.create({ name: "Nur in Datenbank A" });
|
||||
|
||||
assert.equal((await firstProjects.list()).length, 1);
|
||||
assert.deepEqual(await secondProjects.list(), []);
|
||||
|
||||
const firstGlobalDevices = new GlobalDeviceRepository(first.db);
|
||||
const secondGlobalDevices = new GlobalDeviceRepository(second.db);
|
||||
await firstGlobalDevices.create({
|
||||
name: "Steckdose",
|
||||
displayName: "Steckdose",
|
||||
quantity: 1,
|
||||
installedPowerPerUnitKw: 0.2,
|
||||
demandFactor: 1,
|
||||
});
|
||||
|
||||
assert.equal((await firstGlobalDevices.list()).length, 1);
|
||||
assert.deepEqual(await secondGlobalDevices.list(), []);
|
||||
|
||||
assert.deepEqual(
|
||||
await new CircuitListRepository(first.db).listByProject(project.id),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
await new CircuitSectionRepository(first.db).listByCircuitList(
|
||||
"unknown-list"
|
||||
),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
await new CircuitRepository(first.db).listByCircuitList("unknown-list"),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
await new CircuitDeviceRowRepository(first.db).listByCircuitList([]),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
await new FloorRepository(first.db).listByProject(project.id),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
await new RoomRepository(first.db).listByProject(project.id),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
await new ProjectDeviceRepository(first.db).listByProject(project.id),
|
||||
[]
|
||||
);
|
||||
} finally {
|
||||
first.close();
|
||||
second.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { distributionBoards } from "../src/db/schema/distribution-boards.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;
|
||||
}
|
||||
|
||||
describe("distribution board repository integration", () => {
|
||||
it("commits board, circuit list and default sections together", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository = new DistributionBoardRepository(context.db);
|
||||
const board = repository.createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV-01"
|
||||
);
|
||||
|
||||
const persistedBoards = context.db
|
||||
.select()
|
||||
.from(distributionBoards)
|
||||
.where(eq(distributionBoards.id, board.id))
|
||||
.all();
|
||||
const persistedLists = context.db
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.distributionBoardId, board.id))
|
||||
.all();
|
||||
const persistedSections = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, board.id))
|
||||
.all();
|
||||
|
||||
assert.deepEqual(persistedBoards, [board]);
|
||||
assert.equal(persistedLists.length, 1);
|
||||
assert.equal(persistedLists[0].name, "UV-01 Stromkreisliste");
|
||||
assert.deepEqual(
|
||||
persistedSections
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map((section) => [section.key, section.prefix]),
|
||||
[
|
||||
["lighting", "-1F"],
|
||||
["single_phase", "-2F"],
|
||||
["three_phase", "-3F"],
|
||||
["unassigned", "-UF"],
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back board and circuit list when default section creation fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_default_section_insert
|
||||
BEFORE INSERT ON circuit_sections
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced section failure');
|
||||
END;
|
||||
`);
|
||||
const repository = new DistributionBoardRepository(context.db);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV rollback"
|
||||
),
|
||||
/forced section failure/
|
||||
);
|
||||
|
||||
assert.equal(context.db.select().from(distributionBoards).all().length, 0);
|
||||
assert.equal(context.db.select().from(circuitLists).all().length, 0);
|
||||
assert.equal(context.db.select().from(circuitSections).all().length, 0);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
@@ -23,7 +23,7 @@ function createTestDatabase() {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
|
||||
const board = new DistributionBoardRepository(
|
||||
const board = new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const [section] = context.db
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type {
|
||||
LegacyConsumerMigrationDependencies,
|
||||
LegacyMigrationCircuitInput,
|
||||
LegacyMigrationReportInput,
|
||||
} from "../src/domain/ports/legacy-consumer-migration.store.js";
|
||||
import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js";
|
||||
|
||||
describe("legacy consumer migration service", () => {
|
||||
it("plans the migration through injected readers and persists one complete batch", async () => {
|
||||
let defaultsCreatedFor: string | null = null;
|
||||
let persisted:
|
||||
| {
|
||||
circuitListId: string;
|
||||
circuits: LegacyMigrationCircuitInput[];
|
||||
report: LegacyMigrationReportInput;
|
||||
}
|
||||
| undefined;
|
||||
const dependencies: LegacyConsumerMigrationDependencies = {
|
||||
circuitListReader: {
|
||||
async findById(projectId, circuitListId) {
|
||||
assert.equal(projectId, "project-1");
|
||||
assert.equal(circuitListId, "list-1");
|
||||
return { id: circuitListId };
|
||||
},
|
||||
},
|
||||
sectionStore: {
|
||||
async createDefaults(circuitListId) {
|
||||
defaultsCreatedFor = circuitListId;
|
||||
},
|
||||
async listByCircuitList() {
|
||||
return [
|
||||
{ id: "section-1", key: "single_phase", prefix: "-2F" },
|
||||
{ id: "section-2", key: "unassigned", prefix: "-XF" },
|
||||
];
|
||||
},
|
||||
},
|
||||
circuitReader: {
|
||||
async listByCircuitList() {
|
||||
return [
|
||||
{
|
||||
sectionId: "section-1",
|
||||
equipmentIdentifier: "-2F2",
|
||||
sortOrder: 10,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
roomReader: {
|
||||
async listByProject() {
|
||||
return [
|
||||
{ id: "room-1", roomNumber: "1.01", roomName: "Besprechung" },
|
||||
];
|
||||
},
|
||||
},
|
||||
migrationStore: {
|
||||
async listSourceConsumersByCircuitList() {
|
||||
return [
|
||||
{
|
||||
id: "consumer-1",
|
||||
projectDeviceId: null,
|
||||
roomId: "room-1",
|
||||
circuitNumber: null,
|
||||
description: "Steckdosen Besprechung",
|
||||
name: "Steckdose",
|
||||
category: null,
|
||||
deviceType: null,
|
||||
phaseType: null,
|
||||
tradeOrCostGroup: "KG 440",
|
||||
protectionType: null,
|
||||
protectionRatedCurrent: null,
|
||||
protectionCharacteristic: null,
|
||||
cableType: null,
|
||||
cableCrossSection: null,
|
||||
comment: null,
|
||||
quantity: 2,
|
||||
installedPowerPerUnitKw: 0.2,
|
||||
demandFactor: 0.8,
|
||||
voltageV: 230,
|
||||
phaseCount: 1,
|
||||
powerFactor: 0.95,
|
||||
note: "Bestand",
|
||||
},
|
||||
];
|
||||
},
|
||||
async listMigratedConsumerIds() {
|
||||
return [];
|
||||
},
|
||||
persistCircuitListMigration(input) {
|
||||
persisted = input;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const report = await new LegacyConsumerMigrationService(
|
||||
dependencies
|
||||
).migrateCircuitList("project-1", "list-1");
|
||||
|
||||
assert.equal(defaultsCreatedFor, "list-1");
|
||||
assert.deepEqual(report.generatedIdentifiers, ["-2F3"]);
|
||||
assert.equal(report.createdCircuitCount, 1);
|
||||
assert.equal(report.createdDeviceRowCount, 1);
|
||||
assert.equal(persisted?.circuitListId, "list-1");
|
||||
assert.equal(persisted?.circuits[0].circuit.sectionId, "section-1");
|
||||
assert.equal(
|
||||
persisted?.circuits[0].circuit.equipmentIdentifier,
|
||||
"-2F3"
|
||||
);
|
||||
assert.deepEqual(persisted?.circuits[0].deviceRows[0], {
|
||||
linkedProjectDeviceId: undefined,
|
||||
legacyConsumerId: "consumer-1",
|
||||
sortOrder: 10,
|
||||
name: "Steckdose",
|
||||
displayName: "Steckdosen Besprechung",
|
||||
phaseType: undefined,
|
||||
connectionKind: undefined,
|
||||
costGroup: "KG 440",
|
||||
category: undefined,
|
||||
roomId: "room-1",
|
||||
roomNumberSnapshot: "1.01",
|
||||
roomNameSnapshot: "Besprechung",
|
||||
quantity: 2,
|
||||
powerPerUnit: 0.2,
|
||||
simultaneityFactor: 0.8,
|
||||
cosPhi: 0.95,
|
||||
remark: "Bestand",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,9 +14,10 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
|
||||
import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
|
||||
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
|
||||
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
|
||||
@@ -29,6 +30,8 @@ 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 { floors } from "../src/db/schema/floors.js";
|
||||
import { rooms } from "../src/db/schema/rooms.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";
|
||||
@@ -46,6 +49,12 @@ import {
|
||||
createDistributionBoardInsertProjectCommand,
|
||||
createDistributionBoardStructureSnapshot,
|
||||
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
|
||||
import {
|
||||
createProjectFloorInsertProjectCommand,
|
||||
createProjectFloorSnapshot,
|
||||
createProjectRoomInsertProjectCommand,
|
||||
createProjectRoomSnapshot,
|
||||
} from "../src/domain/models/project-location-structure-project-command.model.js";
|
||||
import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js";
|
||||
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
|
||||
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
|
||||
@@ -61,7 +70,7 @@ function createTestDatabase(): DatabaseContext {
|
||||
.insert(projects)
|
||||
.values({ id: "project-1", name: "Test project" })
|
||||
.run();
|
||||
const board = new DistributionBoardRepository(
|
||||
const board = new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const section = context.db
|
||||
@@ -105,6 +114,7 @@ function createService(context: DatabaseContext) {
|
||||
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
|
||||
new CircuitStructureProjectCommandRepository(context.db),
|
||||
new DistributionBoardStructureProjectCommandRepository(context.db),
|
||||
new ProjectLocationStructureProjectCommandRepository(context.db),
|
||||
new CircuitSectionReorderProjectCommandRepository(context.db),
|
||||
new CircuitSectionRenumberProjectCommandRepository(context.db),
|
||||
new ProjectDeviceStructureProjectCommandRepository(context.db),
|
||||
@@ -1036,6 +1046,68 @@ describe("project command service", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches floor and room setup with their persisted inverses", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const service = createService(context);
|
||||
const floor = createProjectFloorSnapshot(
|
||||
"project-1",
|
||||
"EG",
|
||||
0
|
||||
);
|
||||
service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
command: createProjectFloorInsertProjectCommand(floor),
|
||||
});
|
||||
const room = createProjectRoomSnapshot("project-1", {
|
||||
floorId: floor.id,
|
||||
roomNumber: "001",
|
||||
roomName: "Technik",
|
||||
});
|
||||
const created = service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
command: createProjectRoomInsertProjectCommand(room),
|
||||
});
|
||||
assert.equal(created.history.currentRevision, 2);
|
||||
assert.ok(
|
||||
context.db
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(eq(rooms.id, room.id))
|
||||
.get()
|
||||
);
|
||||
|
||||
createService(context).undo({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(eq(rooms.id, room.id))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
createService(context).undo({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 3,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(floors)
|
||||
.where(eq(floors.id, floor.id))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsupported and malformed commands before domain writes", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
@@ -37,7 +37,7 @@ function createTestDatabase(): TestFixture {
|
||||
{ id: "project-2", name: "Other project" },
|
||||
])
|
||||
.run();
|
||||
const boards = new DistributionBoardRepository(context.db);
|
||||
const boards = new DistributionBoardFixtureRepository(context.db);
|
||||
const board = boards.createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV-01"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
@@ -53,7 +53,7 @@ function createTestDatabase(): DatabaseContext {
|
||||
{ id: "project-2", name: "Other project" },
|
||||
])
|
||||
.run();
|
||||
const boards = new DistributionBoardRepository(context.db);
|
||||
const boards = new DistributionBoardFixtureRepository(context.db);
|
||||
const board = boards.createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV-01"
|
||||
|
||||
@@ -59,12 +59,12 @@ function createService() {
|
||||
const service = new ProjectDeviceSyncService({
|
||||
projectDeviceRepository: {
|
||||
async findById() {
|
||||
return projectDevice() as never;
|
||||
return projectDevice();
|
||||
},
|
||||
},
|
||||
deviceRowRepository: {
|
||||
async listLinkedByProjectDevice() {
|
||||
return rows as never;
|
||||
return rows;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} 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 { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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";
|
||||
@@ -30,7 +30,7 @@ function createTestDatabase(): DatabaseContext {
|
||||
.insert(projects)
|
||||
.values({ id: "project-1", name: "Test project" })
|
||||
.run();
|
||||
const board = new DistributionBoardRepository(
|
||||
const board = new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const section = context.db
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
|
||||
import { consumers } from "../src/db/schema/consumers.js";
|
||||
import { floors } from "../src/db/schema/floors.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 {
|
||||
createProjectFloorInsertProjectCommand,
|
||||
createProjectFloorSnapshot,
|
||||
createProjectRoomInsertProjectCommand,
|
||||
createProjectRoomSnapshot,
|
||||
} from "../src/domain/models/project-location-structure-project-command.model.js";
|
||||
import { createFloor, createRoom } from "../src/frontend/utils/api.js";
|
||||
import {
|
||||
createFloorSchema,
|
||||
createRoomSchema,
|
||||
} from "../src/shared/validation/project-structure.schemas.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values([
|
||||
{ id: "project-1", name: "Test project" },
|
||||
{ id: "project-2", name: "Foreign project" },
|
||||
])
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
function getCounts(context: DatabaseContext) {
|
||||
return {
|
||||
floors: context.db.select().from(floors).all().length,
|
||||
rooms: context.db.select().from(rooms).all().length,
|
||||
revisions: context.db.select().from(projectRevisions).all().length,
|
||||
};
|
||||
}
|
||||
|
||||
describe("project-location structure project command", () => {
|
||||
it("normalizes snapshots and sends optimistic frontend revisions", async () => {
|
||||
const floor = createProjectFloorSnapshot("project-1", " EG ", 0);
|
||||
const room = createProjectRoomSnapshot("project-1", {
|
||||
floorId: floor.id,
|
||||
roomNumber: " 001 ",
|
||||
roomName: " Technik ",
|
||||
});
|
||||
assert.equal(floor.name, "EG");
|
||||
assert.equal(room.roomNumber, "001");
|
||||
assert.equal(room.roomName, "Technik");
|
||||
assert.throws(
|
||||
() =>
|
||||
createProjectFloorInsertProjectCommand({
|
||||
...floor,
|
||||
name: " EG ",
|
||||
}),
|
||||
/normalized/
|
||||
);
|
||||
assert.equal(
|
||||
createFloorSchema.safeParse({ name: "EG" }).success,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
createRoomSchema.safeParse({
|
||||
expectedRevision: 0,
|
||||
roomNumber: "001",
|
||||
roomName: "Technik",
|
||||
unknown: true,
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
|
||||
const requests: Array<{ url: string; body: unknown }> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requests.push({
|
||||
url: String(input),
|
||||
body: JSON.parse(String(init?.body)),
|
||||
});
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
await createFloor("project-1", { name: "EG" }, 7);
|
||||
await createRoom(
|
||||
"project-1",
|
||||
{
|
||||
floorId: "floor-1",
|
||||
roomNumber: "001",
|
||||
roomName: "Technik",
|
||||
},
|
||||
8
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.deepEqual(requests, [
|
||||
{
|
||||
url: "/api/projects/project-1/floors",
|
||||
body: { name: "EG", expectedRevision: 7 },
|
||||
},
|
||||
{
|
||||
url: "/api/projects/project-1/rooms",
|
||||
body: {
|
||||
floorId: "floor-1",
|
||||
roomNumber: "001",
|
||||
roomName: "Technik",
|
||||
expectedRevision: 8,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a stable floor and supports persisted undo and redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new ProjectLocationStructureProjectCommandRepository(context.db);
|
||||
const history = new ProjectHistoryRepository(context.db);
|
||||
const floor = createProjectFloorSnapshot("project-1", "EG", 0);
|
||||
const command = createProjectFloorInsertProjectCommand(floor);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
assert.deepEqual(getCounts(context), {
|
||||
floors: 1,
|
||||
rooms: 0,
|
||||
revisions: 1,
|
||||
});
|
||||
|
||||
const undoStep = history.getNextCommand("project-1", "undo");
|
||||
assert.ok(undoStep);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undoStep.changeSetId,
|
||||
command: inserted.inverse,
|
||||
});
|
||||
assert.deepEqual(getCounts(context), {
|
||||
floors: 0,
|
||||
rooms: 0,
|
||||
revisions: 2,
|
||||
});
|
||||
|
||||
const redoStep = history.getNextCommand("project-1", "redo");
|
||||
assert.ok(redoStep);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "redo",
|
||||
historyTargetChangeSetId: redoStep.changeSetId,
|
||||
command,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(floors)
|
||||
.where(eq(floors.id, floor.id))
|
||||
.get(),
|
||||
floor
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a stable room and supports persisted undo and redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
context.db
|
||||
.insert(floors)
|
||||
.values({
|
||||
id: "floor-1",
|
||||
projectId: "project-1",
|
||||
name: "EG",
|
||||
sortOrder: 0,
|
||||
})
|
||||
.run();
|
||||
const repository =
|
||||
new ProjectLocationStructureProjectCommandRepository(context.db);
|
||||
const history = new ProjectHistoryRepository(context.db);
|
||||
const room = createProjectRoomSnapshot("project-1", {
|
||||
floorId: "floor-1",
|
||||
roomNumber: "001",
|
||||
roomName: "Technik",
|
||||
});
|
||||
const command = createProjectRoomInsertProjectCommand(room);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
const undoStep = history.getNextCommand("project-1", "undo");
|
||||
assert.ok(undoStep);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undoStep.changeSetId,
|
||||
command: inserted.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(eq(rooms.id, room.id))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
|
||||
const redoStep = history.getNextCommand("project-1", "redo");
|
||||
assert.ok(redoStep);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "redo",
|
||||
historyTargetChangeSetId: redoStep.changeSetId,
|
||||
command,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(eq(rooms.id, room.id))
|
||||
.get(),
|
||||
room
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects foreign, stale and changed locations without partial writes", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
context.db
|
||||
.insert(floors)
|
||||
.values({
|
||||
id: "foreign-floor",
|
||||
projectId: "project-2",
|
||||
name: "Fremd",
|
||||
sortOrder: 0,
|
||||
})
|
||||
.run();
|
||||
const repository =
|
||||
new ProjectLocationStructureProjectCommandRepository(context.db);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createProjectRoomInsertProjectCommand(
|
||||
createProjectRoomSnapshot("project-1", {
|
||||
floorId: "foreign-floor",
|
||||
roomNumber: "001",
|
||||
roomName: "Fremdraum",
|
||||
})
|
||||
),
|
||||
}),
|
||||
/foreign floor/
|
||||
);
|
||||
const floor = createProjectFloorSnapshot("project-1", "EG", 0);
|
||||
const command = createProjectFloorInsertProjectCommand(floor);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command,
|
||||
}),
|
||||
ProjectRevisionConflictError
|
||||
);
|
||||
assert.deepEqual(getCounts(context), {
|
||||
floors: 1,
|
||||
rooms: 0,
|
||||
revisions: 0,
|
||||
});
|
||||
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
context.db
|
||||
.update(floors)
|
||||
.set({ name: "Direkt geändert" })
|
||||
.where(eq(floors.id, floor.id))
|
||||
.run();
|
||||
const undoStep = new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo");
|
||||
assert.ok(undoStep);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undoStep.changeSetId,
|
||||
command: inserted.inverse,
|
||||
}),
|
||||
/changed before deletion/
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
1
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses referenced deletes and rolls back late history failures", () => {
|
||||
const floorContext = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new ProjectLocationStructureProjectCommandRepository(
|
||||
floorContext.db
|
||||
);
|
||||
const floor = createProjectFloorSnapshot("project-1", "EG", 0);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createProjectFloorInsertProjectCommand(floor),
|
||||
});
|
||||
floorContext.db
|
||||
.insert(rooms)
|
||||
.values({
|
||||
id: "room-1",
|
||||
projectId: "project-1",
|
||||
floorId: floor.id,
|
||||
roomNumber: "001",
|
||||
roomName: "Technik",
|
||||
})
|
||||
.run();
|
||||
const undoStep = new ProjectHistoryRepository(
|
||||
floorContext.db
|
||||
).getNextCommand("project-1", "undo");
|
||||
assert.ok(undoStep);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undoStep.changeSetId,
|
||||
command: inserted.inverse,
|
||||
}),
|
||||
/assigned rooms/
|
||||
);
|
||||
} finally {
|
||||
floorContext.close();
|
||||
}
|
||||
|
||||
const roomContext = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new ProjectLocationStructureProjectCommandRepository(
|
||||
roomContext.db
|
||||
);
|
||||
const room = createProjectRoomSnapshot("project-1", {
|
||||
roomNumber: "001",
|
||||
roomName: "Technik",
|
||||
});
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createProjectRoomInsertProjectCommand(room),
|
||||
});
|
||||
roomContext.db
|
||||
.insert(consumers)
|
||||
.values({
|
||||
id: "legacy-consumer-1",
|
||||
projectId: "project-1",
|
||||
roomId: room.id,
|
||||
name: "Bestand",
|
||||
quantity: 1,
|
||||
installedPowerPerUnitKw: 1,
|
||||
demandFactor: 1,
|
||||
})
|
||||
.run();
|
||||
const undoStep = new ProjectHistoryRepository(
|
||||
roomContext.db
|
||||
).getNextCommand("project-1", "undo");
|
||||
assert.ok(undoStep);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undoStep.changeSetId,
|
||||
command: inserted.inverse,
|
||||
}),
|
||||
/referenced project room/
|
||||
);
|
||||
} finally {
|
||||
roomContext.close();
|
||||
}
|
||||
|
||||
const rollbackContext = createTestDatabase();
|
||||
try {
|
||||
rollbackContext.sqlite.exec(`
|
||||
CREATE TRIGGER fail_project_location_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced project-location history failure');
|
||||
END;
|
||||
`);
|
||||
const repository =
|
||||
new ProjectLocationStructureProjectCommandRepository(
|
||||
rollbackContext.db
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createProjectFloorInsertProjectCommand(
|
||||
createProjectFloorSnapshot(
|
||||
"project-1",
|
||||
"Rollback",
|
||||
0
|
||||
)
|
||||
),
|
||||
}),
|
||||
/forced project-location history failure/
|
||||
);
|
||||
assert.deepEqual(getCounts(rollbackContext), {
|
||||
floors: 0,
|
||||
rooms: 0,
|
||||
revisions: 0,
|
||||
});
|
||||
} finally {
|
||||
rollbackContext.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
+48
-44
@@ -5,12 +5,11 @@ import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type AppDatabase,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import {
|
||||
ProjectRevisionConflictError,
|
||||
ProjectRevisionRepository,
|
||||
} from "../src/db/repositories/project-revision.repository.js";
|
||||
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||
import { appendProjectRevision } from "../src/db/repositories/project-revision.persistence.js";
|
||||
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
|
||||
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
@@ -27,40 +26,50 @@ function createTestDatabase(): DatabaseContext {
|
||||
}
|
||||
|
||||
function appendTestRevision(
|
||||
repository: ProjectRevisionRepository,
|
||||
database: AppDatabase,
|
||||
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" },
|
||||
},
|
||||
});
|
||||
return database.transaction((tx) =>
|
||||
appendProjectRevision(tx, {
|
||||
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", () => {
|
||||
function getCurrentRevision(database: AppDatabase, projectId: string) {
|
||||
return (
|
||||
database
|
||||
.select({ currentRevision: projects.currentRevision })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get()?.currentRevision ?? null
|
||||
);
|
||||
}
|
||||
|
||||
describe("project revision persistence", () => {
|
||||
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(getCurrentRevision(context.db, "project-1"), 0);
|
||||
const appended = appendTestRevision(context.db, 0);
|
||||
|
||||
assert.equal(appended.projectId, "project-1");
|
||||
assert.equal(appended.revisionNumber, 1);
|
||||
assert.equal(repository.getCurrentRevision("project-1"), 1);
|
||||
assert.equal(getCurrentRevision(context.db, "project-1"), 1);
|
||||
|
||||
const revision = context.db
|
||||
.select()
|
||||
@@ -105,9 +114,9 @@ describe("project revision repository", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const next = appendTestRevision(repository, 1);
|
||||
const next = appendTestRevision(context.db, 1);
|
||||
assert.equal(next.revisionNumber, 2);
|
||||
assert.equal(repository.getCurrentRevision("project-1"), 2);
|
||||
assert.equal(getCurrentRevision(context.db, "project-1"), 2);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
@@ -116,18 +125,17 @@ describe("project revision repository", () => {
|
||||
it("rejects a stale expected revision without writing history", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository = new ProjectRevisionRepository(context.db);
|
||||
appendTestRevision(repository, 0);
|
||||
appendTestRevision(context.db, 0);
|
||||
|
||||
assert.throws(
|
||||
() => appendTestRevision(repository, 0),
|
||||
() => appendTestRevision(context.db, 0),
|
||||
(error) =>
|
||||
error instanceof ProjectRevisionConflictError &&
|
||||
error.expectedRevision === 0 &&
|
||||
error.actualRevision === 1
|
||||
);
|
||||
|
||||
assert.equal(repository.getCurrentRevision("project-1"), 1);
|
||||
assert.equal(getCurrentRevision(context.db, "project-1"), 1);
|
||||
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
|
||||
assert.equal(context.db.select().from(projectChangeSets).all().length, 1);
|
||||
} finally {
|
||||
@@ -145,14 +153,12 @@ describe("project revision repository", () => {
|
||||
SELECT RAISE(ABORT, 'forced change-set failure');
|
||||
END;
|
||||
`);
|
||||
const repository = new ProjectRevisionRepository(context.db);
|
||||
|
||||
assert.throws(
|
||||
() => appendTestRevision(repository, 0),
|
||||
() => appendTestRevision(context.db, 0),
|
||||
/forced change-set failure/
|
||||
);
|
||||
|
||||
assert.equal(repository.getCurrentRevision("project-1"), 0);
|
||||
assert.equal(getCurrentRevision(context.db, "project-1"), 0);
|
||||
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
|
||||
} finally {
|
||||
@@ -163,7 +169,6 @@ describe("project revision repository", () => {
|
||||
it("captures and retains automatic snapshots without deleting named snapshots", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const revisions = new ProjectRevisionRepository(context.db);
|
||||
const snapshots = new ProjectSnapshotRepository(context.db);
|
||||
const named = snapshots.createNamed({
|
||||
projectId: "project-1",
|
||||
@@ -177,7 +182,7 @@ describe("project revision repository", () => {
|
||||
expectedRevision < 325;
|
||||
expectedRevision += 1
|
||||
) {
|
||||
appendTestRevision(revisions, expectedRevision);
|
||||
appendTestRevision(context.db, expectedRevision);
|
||||
}
|
||||
|
||||
const stored = context.db
|
||||
@@ -234,13 +239,12 @@ describe("project revision repository", () => {
|
||||
it("rolls back revision 25 when automatic snapshot persistence fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository = new ProjectRevisionRepository(context.db);
|
||||
for (
|
||||
let expectedRevision = 0;
|
||||
expectedRevision < 24;
|
||||
expectedRevision += 1
|
||||
) {
|
||||
appendTestRevision(repository, expectedRevision);
|
||||
appendTestRevision(context.db, expectedRevision);
|
||||
}
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_automatic_project_snapshot
|
||||
@@ -252,10 +256,10 @@ describe("project revision repository", () => {
|
||||
`);
|
||||
|
||||
assert.throws(
|
||||
() => appendTestRevision(repository, 24),
|
||||
() => appendTestRevision(context.db, 24),
|
||||
/forced automatic snapshot failure/
|
||||
);
|
||||
assert.equal(repository.getCurrentRevision("project-1"), 24);
|
||||
assert.equal(getCurrentRevision(context.db, "project-1"), 24);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
24
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
@@ -37,7 +37,7 @@ function createTestDatabase(): DatabaseContext {
|
||||
threePhaseVoltageV: 400,
|
||||
})
|
||||
.run();
|
||||
const board = new DistributionBoardRepository(
|
||||
const board = new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const list = context.db
|
||||
|
||||
@@ -14,12 +14,13 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
|
||||
import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
|
||||
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
|
||||
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
|
||||
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
|
||||
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
||||
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
|
||||
@@ -49,7 +50,7 @@ function createTestDatabase(): DatabaseContext {
|
||||
.insert(projects)
|
||||
.values({ id: "project-1", name: "Ausgangsprojekt" })
|
||||
.run();
|
||||
const board = new DistributionBoardRepository(
|
||||
const board = new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const list = context.db
|
||||
@@ -134,6 +135,7 @@ function createService(context: DatabaseContext) {
|
||||
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
|
||||
new CircuitStructureProjectCommandRepository(context.db),
|
||||
new DistributionBoardStructureProjectCommandRepository(context.db),
|
||||
new ProjectLocationStructureProjectCommandRepository(context.db),
|
||||
new CircuitSectionReorderProjectCommandRepository(context.db),
|
||||
new CircuitSectionRenumberProjectCommandRepository(context.db),
|
||||
new ProjectDeviceStructureProjectCommandRepository(context.db),
|
||||
@@ -192,7 +194,7 @@ function changeCompleteProjectState(context: DatabaseContext) {
|
||||
simultaneityFactor: 1,
|
||||
})
|
||||
.run();
|
||||
new DistributionBoardRepository(
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-02");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AppDatabase } from "../../src/db/database-context.js";
|
||||
import { circuitLists } from "../../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../../src/db/schema/circuit-sections.js";
|
||||
import { distributionBoards } from "../../src/db/schema/distribution-boards.js";
|
||||
import { createDistributionBoardStructureSnapshot } from "../../src/domain/models/distribution-board-structure-project-command.model.js";
|
||||
|
||||
export class DistributionBoardFixtureRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
createWithCircuitListAndDefaultSections(
|
||||
projectId: string,
|
||||
name: string
|
||||
) {
|
||||
const structure = createDistributionBoardStructureSnapshot(
|
||||
projectId,
|
||||
name
|
||||
);
|
||||
this.database.transaction((tx) => {
|
||||
tx.insert(distributionBoards)
|
||||
.values(structure.distributionBoard)
|
||||
.run();
|
||||
tx.insert(circuitLists).values(structure.circuitList).run();
|
||||
tx.insert(circuitSections).values(structure.sections).run();
|
||||
});
|
||||
return structure.distributionBoard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["scripts/**/*.ts", "src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user