Compare commits

...

12 Commits

84 changed files with 2810 additions and 1464 deletions
+24
View File
@@ -21,6 +21,24 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
`src/frontend/components/circuit-grid-*.ts` modules. `src/frontend/components/circuit-grid-*.ts` modules.
- Critical multi-write commands use injected transaction repositories with real - Critical multi-write commands use injected transaction repositories with real
SQLite commit/rollback tests. 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 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 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 stable snapshot of the board, circuit list and four default sections. Its
inverse removes only the same unchanged and still-empty structure; the POST inverse removes only the same unchanged and still-empty structure; the POST
route requires `expectedRevision` and returns the updated history state. 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: Required operations:
@@ -341,6 +364,7 @@ For a normal code change run the relevant focused tests plus:
- `npm test` - `npm test`
- `npm run build:api` - `npm run build:api`
- `npm run build:web` - `npm run build:web`
- `npm run typecheck:scripts`
- `npx tsc --noEmit -p tsconfig.next.json` - `npx tsc --noEmit -p tsconfig.next.json`
Use a concise imperative commit message for each completed, verified work package. Use a concise imperative commit message for each completed, verified work package.
+17
View File
@@ -190,6 +190,23 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
structure; Redo restores the same ids structure; Redo restores the same ids
- stale revisions return `409 PROJECT_REVISION_CONFLICT` - 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 ### Tree Endpoint
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree` - `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
+47 -3
View File
@@ -47,6 +47,8 @@ liegt über einen Host-Mount außerhalb des Containers.
- `src/frontend/utils/api.ts` typisierte Frontend-API-Aufrufe - `src/frontend/utils/api.ts` typisierte Frontend-API-Aufrufe
- `src/server/index.ts` und `src/server/routes/` API-Komposition - `src/server/index.ts` und `src/server/routes/` API-Komposition
- `src/domain/services/` fachliche Command- und Synchronisierungsregeln - `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/repositories/` Abfragen, Persistenzmapper und Transaktionsadapter
- `src/db/schema/` und `src/db/migrations/` SQLite-Schema und Migrationen - `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 Laden und nach jedem Tree-Reload liest er den persistenten History-Status und
gleicht dessen Revision mit `currentRevision` des Trees ab. Das Datenmodell gleicht dessen Revision mit `currentRevision` des Trees ab. Das Datenmodell
besitzt einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set- besitzt einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch Tabellen. Eine direkt getestete Persistence-Funktion schreibt diese
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen 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 versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten 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 verwenden typisierte Command-Stores, die Fachänderung, automatisch erzeugtes
inverses Kommando, Revision und Historienstapel gemeinsam committen inverses Kommando, Revision und Historienstapel gemeinsam committen
beziehungsweise zurückrollen. 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 `GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler
Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über
öffentliche Command-, Undo- und Redo-Endpunkte aus. ö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 `circuit-device-row.insert` und `circuit-device-row.delete` sind atomare
Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen
Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte wiederherstellt. 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 vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
Projektseite übergeben die erwartete Projektrevision; der frühere direkte Projektseite übergeben die erwartete Projektrevision; der frühere direkte
Controller-Schreibweg ist entfernt. 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 ## 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:migrate` wendet Drizzle-Migrationen an.
- `npm run db:verify:circuit-schema` prüft erforderliche und entfernte Spalten. - `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 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. - Angewendete Migrationen werden niemals nachträglich verändert.
- `db:migrate:legacy-consumers` ist Upgrade-Werkzeug, kein Anwendungspfad. - `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 PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
+4
View File
@@ -70,6 +70,7 @@ Beispiel `Preserve circuit blocks during filtering`.
npm test npm test
npm run build:api npm run build:api
npm run build:web npm run build:web
npm run typecheck:scripts
npx tsc --noEmit -p tsconfig.next.json npx tsc --noEmit -p tsconfig.next.json
git diff --check 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. 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 ## Migrationen
@@ -270,11 +270,18 @@ Completed foundation:
list and default-section structure with stable ids; its delete inverse list and default-section structure with stable ids; its delete inverse
refuses changed or populated structures and the project-page POST tracks the refuses changed or populated structures and the project-page POST tracks the
returned revision 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: Remaining constraints before completing project version history:
- extend project-scoped commands only when further project mutations are - all current normal project-scoped runtime writes are behind the revision
deliberately added to history; the generic versioned envelope is complete 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 - do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests - keep database backup/restore checks separate from project history tests
+26 -3
View File
@@ -303,6 +303,8 @@ Acceptance criteria:
## Phase 11: Persistence and Transaction Foundation ## Phase 11: Persistence and Transaction Foundation
Status: Complete.
Goal: Goal:
Prepare the current SQLite implementation for persistent history and a later PostgreSQL adapter without changing user-facing behavior. 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 - database contexts can be created independently from the production singleton
- SQLite foreign-key enforcement is enabled explicitly for every context - SQLite foreign-key enforcement is enabled explicitly for every context
- the distribution-board repository receives its database dependency explicitly - the read-only distribution-board repository receives its database dependency explicitly
- distribution-board setup has real in-memory SQLite commit and rollback coverage using production migrations - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - retained legacy rows are accessible only through explicit database upgrade tooling
- project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields - 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 - distribution-board setup uses `distribution-board.insert` with a complete
stable board/list/default-section snapshot; Undo removes only an unchanged stable board/list/default-section snapshot; Undo removes only an unchanged
empty setup and the former direct controller write is removed 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 - revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction in one SQLite transaction
- a stale expected revision produces no history writes - a stale expected revision produces no history writes
+3 -2
View File
@@ -13,9 +13,10 @@
"build": "npm run build:api", "build": "npm run build:api",
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-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.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.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:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
+4 -3
View File
@@ -1,10 +1,11 @@
import { db } from "../src/db/client.js";
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js"; import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js"; import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js";
import { ProjectRepository } from "../src/db/repositories/project.repository.js"; import { ProjectRepository } from "../src/db/repositories/project.repository.js";
const projectRepository = new ProjectRepository(); const projectRepository = new ProjectRepository(db);
const circuitListRepository = new CircuitListRepository(); const circuitListRepository = new CircuitListRepository(db);
const circuitSectionRepository = new CircuitSectionRepository(); const circuitSectionRepository = new CircuitSectionRepository(db);
async function run() { async function run() {
const projects = await projectRepository.list(); const projects = await projectRepository.list();
+12 -5
View File
@@ -1,15 +1,22 @@
import { db } from "../src/db/client.js"; 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 { 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 { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
import { ProjectRepository } from "../src/db/repositories/project.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"; import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js";
const projectRepository = new ProjectRepository(); const projectRepository = new ProjectRepository(db);
const circuitListRepository = new CircuitListRepository(); const circuitListRepository = new CircuitListRepository(db);
const migrationRepository = new LegacyConsumerMigrationRepository(db); const migrationRepository = new LegacyConsumerMigrationRepository(db);
const migrationService = new LegacyConsumerMigrationService( const migrationService = new LegacyConsumerMigrationService({
migrationRepository circuitListReader: circuitListRepository,
); sectionStore: new CircuitSectionRepository(db),
circuitReader: new CircuitRepository(db),
roomReader: new RoomRepository(db),
migrationStore: migrationRepository,
});
async function run() { async function run() {
const projects = await projectRepository.list(); const projects = await projectRepository.list();
+22 -7
View File
@@ -204,14 +204,19 @@ export default function ProjectDetailPage() {
async function handleCreateFloor(event: FormEvent<HTMLFormElement>) { async function handleCreateFloor(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!projectId || !floorName.trim()) { if (!projectId || !project || !floorName.trim()) {
return; return;
} }
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
const created = await createFloor(projectId, { name: floorName.trim() }); const result = await createFloor(
setFloors((current) => [...current, created]); projectId,
{ name: floorName.trim() },
project.currentRevision
);
setFloors((current) => [...current, result.floor]);
applyProjectRevision(result.history.currentRevision);
setFloorName(""); setFloorName("");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden."); 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>) { async function handleCreateRoom(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!projectId || !roomNumber.trim() || !roomName.trim()) { if (
!projectId ||
!project ||
!roomNumber.trim() ||
!roomName.trim()
) {
return; return;
} }
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
const created = await createRoom(projectId, { const result = await createRoom(
projectId,
{
floorId: roomFloorId || undefined, floorId: roomFloorId || undefined,
roomNumber: roomNumber.trim(), roomNumber: roomNumber.trim(),
roomName: roomName.trim(), roomName: roomName.trim(),
}); },
setRooms((current) => [...current, created]); project.currentRevision
);
setRooms((current) => [...current, result.room]);
applyProjectRevision(result.history.currentRevision);
setRoomNumber(""); setRoomNumber("");
setRoomName(""); setRoomName("");
setRoomFloorId(""); setRoomFloorId("");
@@ -19,8 +19,7 @@ import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js"; import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
interface PersistedMoveRow { interface PersistedMoveRow {
id: string; id: string;
@@ -36,29 +35,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
execute(input: ExecuteCircuitDeviceRowMoveCommandInput) { execute(input: ExecuteCircuitDeviceRowMoveCommandInput) {
this.assertSupportedCommand(input.command); this.assertSupportedCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const inverse = this.applyCommand( this.database,
tx, input,
input.projectId, (tx) =>
input.command this.applyCommand(tx, input.projectId, input.command)
); );
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
} }
private assertSupportedCommand( private assertSupportedCommand(
@@ -21,8 +21,7 @@ import {
toCircuitDeviceRowPatchValues, toCircuitDeviceRowPatchValues,
type CircuitDeviceRowPatchInput, type CircuitDeviceRowPatchInput,
} from "./circuit-device-row.persistence.js"; } from "./circuit-device-row.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect; type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
@@ -34,7 +33,17 @@ export class CircuitDeviceRowProjectCommandRepository
executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) { executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) {
assertCircuitDeviceRowUpdateProjectCommand(input.command); assertCircuitDeviceRowUpdateProjectCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransactionWithAppliedForward(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteCircuitDeviceRowUpdateCommandInput
) {
const current = tx const current = tx
.select() .select()
.from(circuitDeviceRows) .from(circuitDeviceRows)
@@ -109,24 +118,10 @@ export class CircuitDeviceRowProjectCommandRepository
throw new Error("Circuit device row changed before command execution."); throw new Error("Circuit device row changed before command execution.");
} }
const revision = appendProjectRevision(tx, { return {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: appliedForward, forward: appliedForward,
inverse, inverse,
}); };
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
} }
private assertLinkedProjectDevice( private assertLinkedProjectDevice(
@@ -21,8 +21,7 @@ import {
assertCircuitDeviceRowReferencesInProject, assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowSnapshot, toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js"; } from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class CircuitDeviceRowStructureProjectCommandRepository export class CircuitDeviceRowStructureProjectCommandRepository
implements CircuitDeviceRowStructureProjectCommandStore implements CircuitDeviceRowStructureProjectCommandStore
@@ -30,30 +29,17 @@ export class CircuitDeviceRowStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) { execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const inverse = this.applyCommand( this.database,
input,
(tx) =>
this.applyCommand(
tx, tx,
input.projectId, input.projectId,
input.source, input.source,
input.command input.command
)
); );
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
} }
private applyCommand( private applyCommand(
@@ -1,38 +1,18 @@
import crypto from "node:crypto";
import { and, asc, eq, inArray } from "drizzle-orm"; 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 { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.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 { export class CircuitDeviceRowRepository {
async findById(rowId: string) { constructor(private readonly database: AppDatabase) {}
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;
}
async listByCircuitList(circuitIds: string[]) { async listByCircuitList(circuitIds: string[]) {
if (!circuitIds.length) { if (!circuitIds.length) {
return []; return [];
} }
return db return this.database
.select() .select()
.from(circuitDeviceRows) .from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.circuitId, circuitIds)) .where(inArray(circuitDeviceRows.circuitId, circuitIds))
@@ -40,7 +20,7 @@ export class CircuitDeviceRowRepository {
} }
async listLinkedByProjectDevice(projectId: string, projectDeviceId: string) { async listLinkedByProjectDevice(projectId: string, projectDeviceId: string) {
return db return this.database
.select({ .select({
id: circuitDeviceRows.id, id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId, circuitId: circuitDeviceRows.circuitId,
@@ -83,34 +63,4 @@ export class CircuitDeviceRowRepository {
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder)); .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));
}
} }
+5 -48
View File
@@ -1,51 +1,17 @@
import { and, eq } from "drizzle-orm"; 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"; import { circuitLists } from "../schema/circuit-lists.js";
export class CircuitListRepository { export class CircuitListRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) { async listByProject(projectId: string) {
const db = this.database;
return db.select().from(circuitLists).where(eq(circuitLists.projectId, projectId)); 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) { async findById(projectId: string, circuitListId: string) {
const db = this.database;
const [row] = await db const [row] = await db
.select() .select()
.from(circuitLists) .from(circuitLists)
@@ -53,13 +19,4 @@ export class CircuitListRepository {
.limit(1); .limit(1);
return row ?? null; 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, toCircuitPatchValues,
type CircuitPatchPersistenceInput, type CircuitPatchPersistenceInput,
} from "./circuit.persistence.js"; } from "./circuit.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitRow = typeof circuits.$inferSelect; type CircuitRow = typeof circuits.$inferSelect;
@@ -31,7 +30,17 @@ export class CircuitProjectCommandRepository
executeUpdate(input: ExecuteCircuitUpdateCommandInput) { executeUpdate(input: ExecuteCircuitUpdateCommandInput) {
assertCircuitUpdateProjectCommand(input.command); assertCircuitUpdateProjectCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteCircuitUpdateCommandInput
) {
const current = tx const current = tx
.select() .select()
.from(circuits) .from(circuits)
@@ -88,24 +97,7 @@ export class CircuitProjectCommandRepository
throw new Error("Circuit changed before command execution."); throw new Error("Circuit changed before command execution.");
} }
const revision = appendProjectRevision(tx, { return inverse;
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
} }
private assertSectionInCircuitList( private assertSectionInCircuitList(
@@ -12,8 +12,7 @@ import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js"; import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class CircuitSectionRenumberProjectCommandRepository export class CircuitSectionRenumberProjectCommandRepository
implements CircuitSectionRenumberProjectCommandStore implements CircuitSectionRenumberProjectCommandStore
@@ -23,7 +22,17 @@ export class CircuitSectionRenumberProjectCommandRepository
execute(input: ExecuteCircuitSectionRenumberCommandInput) { execute(input: ExecuteCircuitSectionRenumberCommandInput) {
assertCircuitSectionRenumberProjectCommand(input.command); assertCircuitSectionRenumberProjectCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteCircuitSectionRenumberCommandInput
) {
const section = tx const section = tx
.select({ .select({
id: circuitSections.id, id: circuitSections.id,
@@ -190,23 +199,7 @@ export class CircuitSectionRenumberProjectCommandRepository
} }
} }
const revision = appendProjectRevision(tx, { return inverse;
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
} }
private createTemporaryIdentifiers( private createTemporaryIdentifiers(
@@ -21,8 +21,7 @@ import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js"; import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type ReorderHistoryCommand = type ReorderHistoryCommand =
| CircuitSectionReorderProjectCommand | CircuitSectionReorderProjectCommand
@@ -36,7 +35,17 @@ export class CircuitSectionReorderProjectCommandRepository
execute(input: ExecuteCircuitSectionReorderCommandInput) { execute(input: ExecuteCircuitSectionReorderCommandInput) {
this.assertSupportedCommand(input.command); this.assertSupportedCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteCircuitSectionReorderCommandInput
) {
const commandSections = this.getCommandSections(input.command); const commandSections = this.getCommandSections(input.command);
const inverseSections = commandSections.map((section) => { const inverseSections = commandSections.map((section) => {
this.assertCompleteCurrentSection( this.assertCompleteCurrentSection(
@@ -67,23 +76,7 @@ export class CircuitSectionReorderProjectCommandRepository
: createCircuitSectionsReorderProjectCommand( : createCircuitSectionsReorderProjectCommand(
inverseSections inverseSections
); );
const revision = appendProjectRevision(tx, { return inverse;
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
} }
private assertSupportedCommand( private assertSupportedCommand(
@@ -1,7 +1,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js"; 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"; import { circuitSections } from "../schema/circuit-sections.js";
export function createDefaultCircuitSectionValues(circuitListId: string) { export function createDefaultCircuitSectionValues(circuitListId: string) {
@@ -13,12 +13,16 @@ export function createDefaultCircuitSectionValues(circuitListId: string) {
} }
export class CircuitSectionRepository { export class CircuitSectionRepository {
constructor(private readonly database: AppDatabase) {}
async findById(sectionId: string) { async findById(sectionId: string) {
const db = this.database;
const [row] = await db.select().from(circuitSections).where(eq(circuitSections.id, sectionId)).limit(1); const [row] = await db.select().from(circuitSections).where(eq(circuitSections.id, sectionId)).limit(1);
return row ?? null; return row ?? null;
} }
async listByCircuitList(circuitListId: string) { async listByCircuitList(circuitListId: string) {
const db = this.database;
return db return db
.select() .select()
.from(circuitSections) .from(circuitSections)
@@ -27,6 +31,7 @@ export class CircuitSectionRepository {
} }
async createDefaults(circuitListId: string) { async createDefaults(circuitListId: string) {
const db = this.database;
for (const entry of createDefaultCircuitSectionValues(circuitListId)) { for (const entry of createDefaultCircuitSectionValues(circuitListId)) {
const existing = await db const existing = await db
.select({ id: circuitSections.id }) .select({ id: circuitSections.id })
@@ -22,8 +22,7 @@ import {
assertCircuitDeviceRowReferencesInProject, assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowSnapshot, toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js"; } from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class CircuitStructureProjectCommandRepository export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore implements CircuitStructureProjectCommandStore
@@ -31,30 +30,17 @@ export class CircuitStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitStructureCommandInput) { execute(input: ExecuteCircuitStructureCommandInput) {
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const inverse = this.applyCommand( this.database,
input,
(tx) =>
this.applyCommand(
tx, tx,
input.projectId, input.projectId,
input.source, input.source,
input.command input.command
)
); );
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
} }
private applyCommand( private applyCommand(
+5 -92
View File
@@ -1,26 +1,12 @@
import crypto from "node:crypto"; import { asc, eq } from "drizzle-orm";
import { and, asc, eq, ne } from "drizzle-orm"; import type { AppDatabase } from "../database-context.js";
import { db } from "../client.js";
import { circuits } from "../schema/circuits.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 { export class CircuitRepository {
async findById(circuitId: string) { constructor(private readonly database: AppDatabase) {}
const [row] = await db.select().from(circuits).where(eq(circuits.id, circuitId)).limit(1);
return row ?? null;
}
async listByCircuitList(circuitListId: string) { async listByCircuitList(circuitListId: string) {
const db = this.database;
return db return db
.select() .select()
.from(circuits) .from(circuits)
@@ -28,85 +14,12 @@ export class CircuitRepository {
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier)); .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) { async listBySection(sectionId: string) {
const db = this.database;
return db return db
.select() .select()
.from(circuits) .from(circuits)
.where(eq(circuits.sectionId, sectionId)) .where(eq(circuits.sectionId, sectionId))
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier)); .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 { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js"; import { distributionBoards } from "../schema/distribution-boards.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class DistributionBoardStructureProjectCommandRepository export class DistributionBoardStructureProjectCommandRepository
implements DistributionBoardStructureProjectCommandStore implements DistributionBoardStructureProjectCommandStore
@@ -28,29 +27,12 @@ export class DistributionBoardStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteDistributionBoardStructureCommandInput) { execute(input: ExecuteDistributionBoardStructureCommandInput) {
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const inverse = this.applyCommand( this.database,
tx, input,
input.projectId, (tx) =>
input.command this.applyCommand(tx, input.projectId, input.command)
); );
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
} }
private applyCommand( private applyCommand(
@@ -1,10 +1,6 @@
import crypto from "node:crypto"; import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js"; 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 { distributionBoards } from "../schema/distribution-boards.js";
import { createDefaultCircuitSectionValues } from "./circuit-section.repository.js";
export class DistributionBoardRepository { export class DistributionBoardRepository {
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
@@ -16,44 +12,4 @@ export class DistributionBoardRepository {
.where(eq(distributionBoards.projectId, projectId)); .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);
}
} }
+5 -25
View File
@@ -1,36 +1,16 @@
import crypto from "node:crypto"; import { asc, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm"; import type { AppDatabase } from "../database-context.js";
import { db } from "../client.js";
import { floors } from "../schema/floors.js"; import { floors } from "../schema/floors.js";
export class FloorRepository { export class FloorRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) { async listByProject(projectId: string) {
const db = this.database;
return db return db
.select() .select()
.from(floors) .from(floors)
.where(eq(floors.projectId, projectId)) .where(eq(floors.projectId, projectId))
.orderBy(asc(floors.sortOrder), asc(floors.name)); .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 crypto from "node:crypto";
import { eq } from "drizzle-orm"; 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 { globalDevices } from "../schema/global-devices.js";
import type { import type {
CreateGlobalDeviceInput, CreateGlobalDeviceInput,
@@ -8,11 +8,15 @@ import type {
} from "../../shared/validation/global-device.schemas.js"; } from "../../shared/validation/global-device.schemas.js";
export class GlobalDeviceRepository { export class GlobalDeviceRepository {
constructor(private readonly database: AppDatabase) {}
async list() { async list() {
const db = this.database;
return db.select().from(globalDevices); return db.select().from(globalDevices);
} }
async create(input: CreateGlobalDeviceInput) { async create(input: CreateGlobalDeviceInput) {
const db = this.database;
const id = crypto.randomUUID(); const id = crypto.randomUUID();
await db.insert(globalDevices).values({ await db.insert(globalDevices).values({
id, id,
@@ -31,6 +35,7 @@ export class GlobalDeviceRepository {
} }
async update(globalDeviceId: string, input: UpdateGlobalDeviceInput) { async update(globalDeviceId: string, input: UpdateGlobalDeviceInput) {
const db = this.database;
await db await db
.update(globalDevices) .update(globalDevices)
.set({ .set({
@@ -49,11 +54,13 @@ export class GlobalDeviceRepository {
} }
async findById(globalDeviceId: string) { async findById(globalDeviceId: string) {
const db = this.database;
const [row] = await db.select().from(globalDevices).where(eq(globalDevices.id, globalDeviceId)).limit(1); const [row] = await db.select().from(globalDevices).where(eq(globalDevices.id, globalDeviceId)).limit(1);
return row ?? null; return row ?? null;
} }
async delete(globalDeviceId: string) { async delete(globalDeviceId: string) {
const db = this.database;
await db.delete(globalDevices).where(eq(globalDevices.id, globalDeviceId)); await db.delete(globalDevices).where(eq(globalDevices.id, globalDeviceId));
} }
} }
@@ -1,38 +1,17 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { eq, inArray, isNull } from "drizzle-orm"; 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 type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { consumers } from "../schema/consumers.js"; import { consumers } from "../schema/consumers.js";
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js"; import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js"; import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
import { import { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
toCircuitDeviceRowCreateValues, import { toCircuitCreateValues } from "./circuit.persistence.js";
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;
}
export class LegacyConsumerMigrationRepository { export class LegacyConsumerMigrationRepository {
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
@@ -69,8 +48,8 @@ export class LegacyConsumerMigrationRepository {
persistCircuitListMigration(input: { persistCircuitListMigration(input: {
circuitListId: string; circuitListId: string;
circuits: LegacyMigrationCircuitPersistenceInput[]; circuits: LegacyMigrationCircuitInput[];
report: LegacyMigrationReportPersistenceInput; report: LegacyMigrationReportInput;
}) { }) {
if (input.circuits.some((entry) => entry.circuit.circuitListId !== input.circuitListId)) { if (input.circuits.some((entry) => entry.circuit.circuitListId !== input.circuitListId)) {
throw new Error("All migrated circuits must belong to the target circuit list."); 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"; } from "../../domain/ports/project-device-project-command.store.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type ProjectDeviceRow = typeof projectDevices.$inferSelect; type ProjectDeviceRow = typeof projectDevices.$inferSelect;
@@ -25,7 +24,17 @@ export class ProjectDeviceProjectCommandRepository
executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) { executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) {
assertProjectDeviceUpdateProjectCommand(input.command); assertProjectDeviceUpdateProjectCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteProjectDeviceUpdateCommandInput
) {
const current = tx const current = tx
.select() .select()
.from(projectDevices) .from(projectDevices)
@@ -78,23 +87,7 @@ export class ProjectDeviceProjectCommandRepository
); );
} }
const revision = appendProjectRevision(tx, { return inverse;
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 };
});
} }
} }
@@ -15,8 +15,7 @@ import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect; type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
@@ -28,7 +27,17 @@ export class ProjectDeviceRowSyncProjectCommandRepository
execute(input: ExecuteProjectDeviceRowSyncCommandInput) { execute(input: ExecuteProjectDeviceRowSyncCommandInput) {
assertProjectDeviceRowSyncProjectCommand(input.command); assertProjectDeviceRowSyncProjectCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteProjectDeviceRowSyncCommandInput
) {
const projectDevice = tx const projectDevice = tx
.select({ id: projectDevices.id }) .select({ id: projectDevices.id })
.from(projectDevices) .from(projectDevices)
@@ -121,23 +130,7 @@ export class ProjectDeviceRowSyncProjectCommandRepository
} }
} }
const revision = appendProjectRevision(tx, { return inverse;
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 };
});
} }
} }
@@ -27,8 +27,7 @@ import { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js"; import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
const circuitDeviceRowSnapshotFields = [ const circuitDeviceRowSnapshotFields = [
"id", "id",
@@ -60,30 +59,17 @@ export class ProjectDeviceStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectDeviceStructureCommandInput) { execute(input: ExecuteProjectDeviceStructureCommandInput) {
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
const inverse = this.applyCommand( this.database,
input,
(tx) =>
this.applyCommand(
tx, tx,
input.projectId, input.projectId,
input.source, input.source,
input.command input.command
)
); );
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
} }
private applyCommand( private applyCommand(
@@ -1,5 +1,5 @@
import { and, eq } from "drizzle-orm"; 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 { projectDevices } from "../schema/project-devices.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js"; import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
@@ -11,12 +11,16 @@ function withTotalPower(row: typeof projectDevices.$inferSelect) {
} }
export class ProjectDeviceRepository { export class ProjectDeviceRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) { async listByProject(projectId: string) {
const db = this.database;
const rows = await db.select().from(projectDevices).where(eq(projectDevices.projectId, projectId)); const rows = await db.select().from(projectDevices).where(eq(projectDevices.projectId, projectId));
return rows.map(withTotalPower); return rows.map(withTotalPower);
} }
async findById(projectId: string, projectDeviceId: string) { async findById(projectId: string, projectDeviceId: string) {
const db = this.database;
const [row] = await db const [row] = await db
.select() .select()
.from(projectDevices) .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"; } from "../../domain/ports/project-settings-project-command.store.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class ProjectSettingsProjectCommandRepository export class ProjectSettingsProjectCommandRepository
implements ProjectSettingsProjectCommandStore implements ProjectSettingsProjectCommandStore
@@ -19,7 +18,17 @@ export class ProjectSettingsProjectCommandRepository
executeUpdate(input: ExecuteProjectSettingsUpdateCommandInput) { executeUpdate(input: ExecuteProjectSettingsUpdateCommandInput) {
assertProjectSettingsUpdateProjectCommand(input.command); assertProjectSettingsUpdateProjectCommand(input.command);
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteProjectSettingsUpdateCommandInput
) {
const current = tx const current = tx
.select() .select()
.from(projects) .from(projects)
@@ -50,22 +59,6 @@ export class ProjectSettingsProjectCommandRepository
throw new Error("Project changed before settings update."); throw new Error("Project changed before settings update.");
} }
const revision = appendProjectRevision(tx, { return inverse;
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 };
});
} }
} }
@@ -25,8 +25,7 @@ import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migrat
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
import { import {
hashProjectStatePayload, hashProjectStatePayload,
readProjectStateSnapshot, readProjectStateSnapshot,
@@ -43,7 +42,17 @@ export class ProjectStateRestoreCommandRepository
throw new Error("Restore state belongs to a different project."); throw new Error("Restore state belongs to a different project.");
} }
return this.database.transaction((tx) => { return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteProjectStateRestoreCommandInput
) {
const current = readProjectStateSnapshot(tx, input.projectId); const current = readProjectStateSnapshot(tx, input.projectId);
if (!current) { if (!current) {
throw new Error("Project not found."); throw new Error("Project not found.");
@@ -69,23 +78,7 @@ export class ProjectStateRestoreCommandRepository
replaceProjectState(tx, input.command.payload.targetState); replaceProjectState(tx, input.command.payload.targetState);
const revision = appendProjectRevision(tx, { return inverse;
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 };
});
} }
} }
+6 -5
View File
@@ -1,17 +1,21 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "../client.js"; import type { AppDatabase } from "../database-context.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import type { import type {
CreateProjectInput, CreateProjectInput,
} from "../../shared/validation/project-structure.schemas.js"; } from "../../shared/validation/project-structure.schemas.js";
export class ProjectRepository { export class ProjectRepository {
constructor(private readonly database: AppDatabase) {}
async list() { async list() {
const db = this.database;
return db.select().from(projects); return db.select().from(projects);
} }
async create(input: CreateProjectInput) { async create(input: CreateProjectInput) {
const db = this.database;
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const project = { const project = {
id, id,
@@ -25,11 +29,8 @@ export class ProjectRepository {
} }
async findById(projectId: string) { async findById(projectId: string) {
const db = this.database;
const [row] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1); const [row] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
return row ?? null; return row ?? null;
} }
async delete(projectId: string) {
await db.delete(projects).where(eq(projects.id, projectId));
}
} }
+5 -31
View File
@@ -1,42 +1,16 @@
import crypto from "node:crypto"; import { asc, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm"; import type { AppDatabase } from "../database-context.js";
import { db } from "../client.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import type { CreateRoomInput } from "../../shared/validation/project-structure.schemas.js";
export class RoomRepository { export class RoomRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) { async listByProject(projectId: string) {
const db = this.database;
return db return db
.select() .select()
.from(rooms) .from(rooms)
.where(eq(rooms.projectId, projectId)) .where(eq(rooms.projectId, projectId))
.orderBy(asc(rooms.roomNumber), asc(rooms.roomName)); .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; revisionNumber: number;
createdAtIso: string; 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"; export interface CircuitNumberingSectionReader {
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; 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 { function parseSuffix(equipmentIdentifier: string, prefix: string): number | null {
if (!equipmentIdentifier.startsWith(prefix)) { if (!equipmentIdentifier.startsWith(prefix)) {
@@ -13,15 +27,12 @@ function parseSuffix(equipmentIdentifier: string, prefix: string): number | null
} }
export class CircuitNumberingService { export class CircuitNumberingService {
private readonly sectionRepository: Pick<CircuitSectionRepository, "findById">; private readonly sectionRepository: CircuitNumberingSectionReader;
private readonly circuitRepository: Pick<CircuitRepository, "listBySection">; private readonly circuitRepository: CircuitNumberingCircuitReader;
constructor(deps?: { constructor(deps: CircuitNumberingDependencies) {
sectionRepository?: Pick<CircuitSectionRepository, "findById">; this.sectionRepository = deps.sectionRepository;
circuitRepository?: Pick<CircuitRepository, "listBySection">; this.circuitRepository = deps.circuitRepository;
}) {
this.sectionRepository = deps?.sectionRepository ?? new CircuitSectionRepository();
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
} }
async getNextIdentifier(sectionId: string) { 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 { LegacyMigrationReport } from "../models/circuit-tree.model.js";
import type {
LegacyConsumerMigrationDependencies,
LegacyConsumerSource,
LegacyMigrationCircuitInput,
} from "../ports/legacy-consumer-migration.store.js";
import { import {
inferSectionKeyFromEquipmentIdentifier, inferSectionKeyFromEquipmentIdentifier,
inferSectionKeyFromLegacyInput, inferSectionKeyFromLegacyInput,
normalizeCircuitNumber, normalizeCircuitNumber,
} from "./legacy-consumer-migration-planner.js"; } from "./legacy-consumer-migration-planner.js";
type LegacyConsumerRow = Awaited< function parseEquipmentSequence(
ReturnType< equipmentIdentifier: string,
LegacyConsumerMigrationRepository["listSourceConsumersByCircuitList"] prefix: string
> ): number | null {
>[number];
function parseEquipmentSequence(equipmentIdentifier: string, prefix: string): number | null {
if (!equipmentIdentifier.startsWith(prefix)) { if (!equipmentIdentifier.startsWith(prefix)) {
return null; return null;
} }
@@ -30,43 +24,45 @@ function parseEquipmentSequence(equipmentIdentifier: string, prefix: string): nu
return Number(suffix); return Number(suffix);
} }
export class LegacyConsumerMigrationService { export class LegacyConsumerMigrationService {
private readonly circuitListRepository = new CircuitListRepository();
private readonly sectionRepository = new CircuitSectionRepository();
private readonly circuitRepository = new CircuitRepository();
private readonly roomRepository = new RoomRepository();
constructor( 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 // Migration is additive: legacy consumers are preserved, and circuit-first entities are created
// with mapping records so transition remains auditable and reversible. // 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) { if (!list) {
throw new Error("Circuit list not found in project."); throw new Error("Circuit list not found in project.");
} }
await this.sectionRepository.createDefaults(circuitListId); await this.dependencies.sectionStore.createDefaults(circuitListId);
const sections = await this.sectionRepository.listByCircuitList(circuitListId); const sections =
await this.dependencies.sectionStore.listByCircuitList(circuitListId);
const sectionByKey = new Map(sections.map((section) => [section.key, section])); const sectionByKey = new Map(sections.map((section) => [section.key, section]));
const unassignedSection = sectionByKey.get("unassigned"); const unassignedSection = sectionByKey.get("unassigned");
if (!unassignedSection) { if (!unassignedSection) {
throw new Error("Unassigned section is required."); 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( const usedEquipmentIdentifiers = new Set(
existingCircuits.map((circuit) => circuit.equipmentIdentifier.toUpperCase()) existingCircuits.map((circuit) => circuit.equipmentIdentifier.toUpperCase())
); );
const legacyConsumers = const legacyConsumers =
await this.migrationRepository.listSourceConsumersByCircuitList( await this.dependencies.migrationStore.listSourceConsumersByCircuitList(
circuitListId 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 roomById = new Map(rooms.map((room) => [room.id, room]));
const report: LegacyMigrationReport = { const report: LegacyMigrationReport = {
@@ -82,14 +78,16 @@ export class LegacyConsumerMigrationService {
// Idempotency guard: skip consumers already mapped in previous migration run. // Idempotency guard: skip consumers already mapped in previous migration run.
const migratedConsumerIds = new Set( const migratedConsumerIds = new Set(
await this.migrationRepository.listMigratedConsumerIds(circuitListId) await this.dependencies.migrationStore.listMigratedConsumerIds(
circuitListId
)
); );
const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id)); const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id));
// Legacy rows are grouped by normalized circuit number so duplicates become // Legacy rows are grouped by normalized circuit number so duplicates become
// multiple device rows within one circuit instead of duplicate circuits. // multiple device rows within one circuit instead of duplicate circuits.
const byNormalizedCircuitNumber = new Map<string, LegacyConsumerRow[]>(); const byNormalizedCircuitNumber = new Map<string, LegacyConsumerSource[]>();
const withoutNormalizedCircuitNumber: LegacyConsumerRow[] = []; const withoutNormalizedCircuitNumber: LegacyConsumerSource[] = [];
for (const consumer of consumersToMigrate) { for (const consumer of consumersToMigrate) {
const normalized = normalizeCircuitNumber(consumer.circuitNumber ?? null); const normalized = normalizeCircuitNumber(consumer.circuitNumber ?? null);
if (!normalized) { if (!normalized) {
@@ -110,7 +108,7 @@ export class LegacyConsumerMigrationService {
const groups: Array<{ const groups: Array<{
equipmentIdentifier: string | null; equipmentIdentifier: string | null;
consumers: LegacyConsumerRow[]; consumers: LegacyConsumerSource[];
inferredSectionKey: string | null; inferredSectionKey: string | null;
isGeneratedIdentifier: boolean; isGeneratedIdentifier: boolean;
}> = []; }> = [];
@@ -152,7 +150,7 @@ export class LegacyConsumerMigrationService {
maxBySectionPrefix.set(section.prefix.toUpperCase(), Math.max(current, sequence)); maxBySectionPrefix.set(section.prefix.toUpperCase(), Math.max(current, sequence));
} }
const migrationCircuits: LegacyMigrationCircuitPersistenceInput[] = []; const migrationCircuits: LegacyMigrationCircuitInput[] = [];
for (const group of groups) { for (const group of groups) {
const representative = group.consumers[0]; const representative = group.consumers[0];
let section = group.inferredSectionKey ? sectionByKey.get(group.inferredSectionKey) : null; let section = group.inferredSectionKey ? sectionByKey.get(group.inferredSectionKey) : null;
@@ -181,7 +179,7 @@ export class LegacyConsumerMigrationService {
usedEquipmentIdentifiers.add(equipmentIdentifier.toUpperCase()); usedEquipmentIdentifiers.add(equipmentIdentifier.toUpperCase());
const deviceRows: LegacyMigrationCircuitPersistenceInput["deviceRows"] = []; const deviceRows: LegacyMigrationCircuitInput["deviceRows"] = [];
const circuitSortOrder = nextSortOrder; const circuitSortOrder = nextSortOrder;
nextSortOrder += 10; nextSortOrder += 10;
@@ -250,7 +248,7 @@ export class LegacyConsumerMigrationService {
report.warnings.push(`Consumer ${row.consumerId} was migrated into unassigned section.`); report.warnings.push(`Consumer ${row.consumerId} was migrated into unassigned section.`);
} }
this.migrationRepository.persistCircuitListMigration({ this.dependencies.migrationStore.persistCircuitListMigration({
circuitListId, circuitListId,
circuits: migrationCircuits, circuits: migrationCircuits,
report: { report: {
@@ -47,6 +47,16 @@ import {
assertSerializedProjectCommand, assertSerializedProjectCommand,
type SerializedProjectCommand, type SerializedProjectCommand,
} from "../models/project-command.model.js"; } 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 { import {
assertProjectStateRestoreCommand, assertProjectStateRestoreCommand,
projectStateRestoreCommandType, projectStateRestoreCommandType,
@@ -87,6 +97,7 @@ import type {
ProjectHistoryDirection, ProjectHistoryDirection,
ProjectHistoryStore, ProjectHistoryStore,
} from "../ports/project-history.store.js"; } 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 { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js"; import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
import type { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.store.js"; import type { 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 deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
private readonly circuitStructureStore: CircuitStructureProjectCommandStore, private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly distributionBoardStructureStore: DistributionBoardStructureProjectCommandStore, private readonly distributionBoardStructureStore: DistributionBoardStructureProjectCommandStore,
private readonly projectLocationStructureStore: ProjectLocationStructureProjectCommandStore,
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore, private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore, private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore, private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
@@ -273,6 +285,34 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).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: { case circuitSectionReorderCommandType: {
assertCircuitSectionReorderProjectCommand(input.command); assertCircuitSectionReorderProjectCommand(input.command);
return this.circuitSectionReorderStore.execute({ 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 { import {
createProjectDeviceRowSyncProjectCommand, createProjectDeviceRowSyncProjectCommand,
projectDeviceSyncRowSnapshotFields, projectDeviceSyncRowSnapshotFields,
@@ -20,15 +18,44 @@ export {
serializeOverriddenFields, serializeOverriddenFields,
} from "./project-device-overrides.js"; } from "./project-device-overrides.js";
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>; export type ProjectDeviceSyncSource = {
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number]; id: string;
} & Pick<ProjectDeviceSyncRowSnapshot, ProjectDeviceSyncField>;
type SyncDependencies = { export type LinkedProjectDeviceRow = ProjectDeviceSyncRowSnapshot & {
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">; id: string;
deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice">; 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]; return projectDevice[field];
} }
@@ -36,7 +63,10 @@ function valuesEqual(left: unknown, right: unknown) {
return (left ?? null) === (right ?? null); return (left ?? null) === (right ?? null);
} }
function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) { function buildDifferences(
projectDevice: ProjectDeviceSyncSource,
row: LinkedProjectDeviceRow
) {
return projectDeviceSyncFields return projectDeviceSyncFields
.filter((field) => !valuesEqual(row[field], sourceValue(projectDevice, field))) .filter((field) => !valuesEqual(row[field], sourceValue(projectDevice, field)))
.map((field) => ({ .map((field) => ({
@@ -48,12 +78,12 @@ function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
} }
export class ProjectDeviceSyncService { export class ProjectDeviceSyncService {
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"]; private readonly projectDeviceRepository: ProjectDeviceSyncSourceReader;
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"]; private readonly deviceRowRepository: LinkedProjectDeviceRowReader;
constructor(deps?: Partial<SyncDependencies>) { constructor(deps: ProjectDeviceSyncDependencies) {
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository(); this.projectDeviceRepository = deps.projectDeviceRepository;
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository(); this.deviceRowRepository = deps.deviceRowRepository;
} }
async getPreview(projectId: string, projectDeviceId: string) { 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 uniqueRowIds = [...new Set(rowIds)];
const byId = new Map(linkedRows.map((row) => [row.id, row])); 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) { if (selectedRows.length !== uniqueRowIds.length) {
throw new Error("One or more rows are not linked to this project device."); 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 { return {
linkedProjectDeviceId: row.linkedProjectDeviceId, linkedProjectDeviceId: row.linkedProjectDeviceId,
name: row.name, name: row.name,
+10
View File
@@ -106,6 +106,11 @@ export interface FloorDto {
sortOrder: number; sortOrder: number;
} }
export interface ProjectFloorCommandResultDto
extends ProjectCommandResultDto {
floor: FloorDto;
}
export interface RoomDto { export interface RoomDto {
id: string; id: string;
projectId: string; projectId: string;
@@ -146,6 +151,11 @@ export interface ProjectDeviceDto {
voltageV: number | null; voltageV: number | null;
} }
export interface ProjectRoomCommandResultDto
extends ProjectCommandResultDto {
room: RoomDto;
}
export interface ProjectDeviceCommandResultDto export interface ProjectDeviceCommandResultDto
extends ProjectCommandResultDto { extends ProjectCommandResultDto {
projectDevice: ProjectDeviceDto; projectDevice: ProjectDeviceDto;
+24 -8
View File
@@ -12,11 +12,13 @@ import type {
ProjectCommandResultDto, ProjectCommandResultDto,
ProjectDeviceCommandResultDto, ProjectDeviceCommandResultDto,
ProjectDeviceDto, ProjectDeviceDto,
ProjectFloorCommandResultDto,
ProjectHistoryStateDto, ProjectHistoryStateDto,
ProjectRevisionPageDto, ProjectRevisionPageDto,
ProjectSnapshotMetadataDto, ProjectSnapshotMetadataDto,
ProjectSnapshotRestoreResultDto, ProjectSnapshotRestoreResultDto,
ProjectSettingsCommandResultDto, ProjectSettingsCommandResultDto,
ProjectRoomCommandResultDto,
ProjectDeviceSyncCommandResultDto, ProjectDeviceSyncCommandResultDto,
ProjectDeviceSyncPreviewDto, ProjectDeviceSyncPreviewDto,
ProjectDto, ProjectDto,
@@ -454,22 +456,36 @@ export function listFloors(projectId: string) {
return request<FloorDto[]>(`/api/projects/${projectId}/floors`); return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
} }
export function createFloor(projectId: string, input: CreateFloorInput) { export function createFloor(
return request<FloorDto>(`/api/projects/${projectId}/floors`, { projectId: string,
input: CreateFloorInput,
expectedRevision: number
) {
return request<ProjectFloorCommandResultDto>(
`/api/projects/${projectId}/floors`,
{
method: "POST", method: "POST",
body: JSON.stringify(input), body: JSON.stringify({ ...input, expectedRevision }),
}); }
);
} }
export function listRooms(projectId: string) { export function listRooms(projectId: string) {
return request<RoomDto[]>(`/api/projects/${projectId}/rooms`); return request<RoomDto[]>(`/api/projects/${projectId}/rooms`);
} }
export function createRoom(projectId: string, input: CreateRoomInput) { export function createRoom(
return request<RoomDto>(`/api/projects/${projectId}/rooms`, { projectId: string,
input: CreateRoomInput,
expectedRevision: number
) {
return request<ProjectRoomCommandResultDto>(
`/api/projects/${projectId}/rooms`,
{
method: "POST", method: "POST",
body: JSON.stringify(input), body: JSON.stringify({ ...input, expectedRevision }),
}); }
);
} }
export function listGlobalDevices() { export function listGlobalDevices() {
@@ -32,6 +32,10 @@ const commandTypeLabels: Record<string, string> = {
"project.update-settings": "Projekteinstellungen bearbeitet", "project.update-settings": "Projekteinstellungen bearbeitet",
"distribution-board.insert": "Verteilung angelegt", "distribution-board.insert": "Verteilung angelegt",
"distribution-board.delete": "Verteilung entfernt", "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", "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 { 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 { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-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 { 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 { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js";
import { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js"; import { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js";
@@ -26,6 +27,8 @@ export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db); new CircuitStructureProjectCommandRepository(db);
export const distributionBoardStructureProjectCommandStore = export const distributionBoardStructureProjectCommandStore =
new DistributionBoardStructureProjectCommandRepository(db); new DistributionBoardStructureProjectCommandRepository(db);
export const projectLocationStructureProjectCommandStore =
new ProjectLocationStructureProjectCommandRepository(db);
export const circuitSectionReorderProjectCommandStore = export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(db); new CircuitSectionReorderProjectCommandRepository(db);
export const circuitSectionRenumberProjectCommandStore = export const circuitSectionRenumberProjectCommandStore =
@@ -48,6 +51,7 @@ export const projectCommandService = new ProjectCommandService(
circuitDeviceRowMoveProjectCommandStore, circuitDeviceRowMoveProjectCommandStore,
circuitStructureProjectCommandStore, circuitStructureProjectCommandStore,
distributionBoardStructureProjectCommandStore, distributionBoardStructureProjectCommandStore,
projectLocationStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore, circuitSectionReorderProjectCommandStore,
circuitSectionRenumberProjectCommandStore, circuitSectionRenumberProjectCommandStore,
projectDeviceStructureProjectCommandStore, projectDeviceStructureProjectCommandStore,
@@ -1,3 +1,10 @@
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js"; 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 type { Request, Response } from "express";
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; import { circuitListRepository } from "../composition/application-repositories.js";
const circuitListRepository = new CircuitListRepository();
export async function listCircuitListsByProject(req: Request, res: Response) { export async function listCircuitListsByProject(req: Request, res: Response) {
const { projectId } = req.params; const { projectId } = req.params;
@@ -1,20 +1,16 @@
import type { Request, Response } from "express"; 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 { import {
calculateCircuitTotalPower, calculateCircuitTotalPower,
calculateRowTotalPower, calculateRowTotalPower,
} from "../../domain/calculations/circuit-power-calculation.js"; } from "../../domain/calculations/circuit-power-calculation.js";
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js"; import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
import {
const circuitListRepository = new CircuitListRepository(); circuitDeviceRowRepository,
const circuitSectionRepository = new CircuitSectionRepository(); circuitListRepository,
const circuitRepository = new CircuitRepository(); circuitRepository,
const circuitDeviceRowRepository = new CircuitDeviceRowRepository(); circuitSectionRepository,
const projectRepository = new ProjectRepository(); projectRepository,
} from "../composition/application-repositories.js";
export function isMissingCircuitTreeSchemaError(error: unknown): boolean { export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
if (!(error instanceof Error)) { if (!(error instanceof Error)) {
@@ -1,16 +1,13 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { db } from "../../db/client.js";
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
import { import {
createDistributionBoardInsertProjectCommand, createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot, createDistributionBoardStructureSnapshot,
} from "../../domain/models/distribution-board-structure-project-command.model.js"; } from "../../domain/models/distribution-board-structure-project-command.model.js";
import { createDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js"; import { createDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js"; import { projectCommandService } from "../composition/project-command-stores.js";
import { distributionBoardRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js"; import { respondWithProjectCommandError } from "./project-command.controller.js";
const distributionBoardRepository = new DistributionBoardRepository(db);
export async function listDistributionBoardsByProject(req: Request, res: Response) { export async function listDistributionBoardsByProject(req: Request, res: Response) {
const { projectId } = req.params; const { projectId } = req.params;
if (typeof projectId !== "string") { if (typeof projectId !== "string") {
+27 -5
View File
@@ -1,8 +1,12 @@
import type { Request, Response } from "express"; 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"; import { createFloorSchema } from "../../shared/validation/project-structure.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
const floorRepository = new FloorRepository(); import { floorRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
export async function listFloorsByProject(req: Request, res: Response) { export async function listFloorsByProject(req: Request, res: Response) {
const { projectId } = req.params; 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() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
const floor = await floorRepository.create(projectId, parsed.data.name); const existingFloors = await floorRepository.listByProject(projectId);
return res.status(201).json(floor); 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 type { Request, Response } from "express";
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import { import {
createGlobalDeviceSchema, createGlobalDeviceSchema,
updateGlobalDeviceSchema, updateGlobalDeviceSchema,
} from "../../shared/validation/global-device.schemas.js"; } from "../../shared/validation/global-device.schemas.js";
import {
const globalDeviceRepository = new GlobalDeviceRepository(); globalDeviceRepository,
const projectDeviceRepository = new ProjectDeviceRepository(); projectDeviceRepository,
} from "../composition/application-repositories.js";
export async function listGlobalDevices(_req: Request, res: Response) { export async function listGlobalDevices(_req: Request, res: Response) {
const rows = await globalDeviceRepository.list(); const rows = await globalDeviceRepository.list();
@@ -1,7 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { Request, Response } from "express"; 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 { createProjectDeviceUpdateProjectCommand } from "../../domain/models/project-device-project-command.model.js";
import { import {
createProjectDeviceDeleteProjectCommand, createProjectDeviceDeleteProjectCommand,
@@ -10,6 +8,10 @@ import {
} from "../../domain/models/project-device-structure-project-command.model.js"; } from "../../domain/models/project-device-structure-project-command.model.js";
import { projectDeviceSyncService } from "../composition/project-device-sync-service.js"; import { projectDeviceSyncService } from "../composition/project-device-sync-service.js";
import { projectCommandService } from "../composition/project-command-stores.js"; import { projectCommandService } from "../composition/project-command-stores.js";
import {
globalDeviceRepository,
projectDeviceRepository,
} from "../composition/application-repositories.js";
import { import {
createProjectDeviceCommandSchema, createProjectDeviceCommandSchema,
disconnectProjectDeviceRowsSchema, disconnectProjectDeviceRowsSchema,
@@ -20,8 +22,6 @@ import {
import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js"; import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js";
import { respondWithProjectCommandError } from "./project-command.controller.js"; import { respondWithProjectCommandError } from "./project-command.controller.js";
const globalDeviceRepository = new GlobalDeviceRepository();
const projectDeviceRepository = new ProjectDeviceRepository();
export async function listProjectDevicesByProject(req: Request, res: Response) { export async function listProjectDevicesByProject(req: Request, res: Response) {
const { projectId } = req.params; const { projectId } = req.params;
if (typeof projectId !== "string") { if (typeof projectId !== "string") {
+1 -3
View File
@@ -1,15 +1,13 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { import {
createProjectSchema, createProjectSchema,
updateProjectSettingsSchema, updateProjectSettingsSchema,
} from "../../shared/validation/project-structure.schemas.js"; } from "../../shared/validation/project-structure.schemas.js";
import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js"; import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js";
import { projectCommandService } from "../composition/project-command-stores.js"; import { projectCommandService } from "../composition/project-command-stores.js";
import { projectRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js"; import { respondWithProjectCommandError } from "./project-command.controller.js";
const projectRepository = new ProjectRepository();
export async function listProjects(_req: Request, res: Response) { export async function listProjects(_req: Request, res: Response) {
const result = await projectRepository.list(); const result = await projectRepository.list();
res.json(result); res.json(result);
+18 -13
View File
@@ -1,10 +1,12 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { FloorRepository } from "../../db/repositories/floor.repository.js"; import {
import { RoomRepository } from "../../db/repositories/room.repository.js"; createProjectRoomInsertProjectCommand,
createProjectRoomSnapshot,
} from "../../domain/models/project-location-structure-project-command.model.js";
import { createRoomSchema } from "../../shared/validation/project-structure.schemas.js"; import { createRoomSchema } from "../../shared/validation/project-structure.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
const floorRepository = new FloorRepository(); import { roomRepository } from "../composition/application-repositories.js";
const roomRepository = new RoomRepository(); import { respondWithProjectCommandError } from "./project-command.controller.js";
export async function listRoomsByProject(req: Request, res: Response) { export async function listRoomsByProject(req: Request, res: Response) {
const { projectId } = req.params; 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() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
if (parsed.data.floorId) { const room = createProjectRoomSnapshot(projectId, parsed.data);
const hasValidFloor = await floorRepository.existsInProject(projectId, parsed.data.floorId); try {
if (!hasValidFloor) { const result = projectCommandService.executeUser({
return res.status(400).json({ error: "Floor does not belong to the provided project." }); 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(); .strict();
export const createFloorSchema = z.object({ export const createFloorSchema = z
name: z.string().min(1), .object({
}); expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1),
})
.strict();
export const createRoomSchema = z.object({ export const createRoomSchema = z
floorId: z.string().min(1).optional(), .object({
roomNumber: z.string().min(1), expectedRevision: expectedProjectRevisionSchema,
roomName: z.string().min(1), 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 CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectSettingsInput = z.infer< export type UpdateProjectSettingsInput = z.infer<
@@ -39,5 +45,3 @@ export type UpdateProjectSettingsInput = z.infer<
export type CreateDistributionBoardInput = z.infer< export type CreateDistributionBoardInput = z.infer<
typeof createDistributionBoardSchema typeof createDistributionBoardSchema
>; >;
export type CreateFloorInput = z.infer<typeof createFloorSchema>;
export type CreateRoomInput = z.infer<typeof createRoomSchema>;
@@ -8,7 +8,7 @@ import {
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } from "../src/db/database-context.js";
import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-project-command.repository.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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
@@ -39,7 +39,7 @@ function createTestDatabase(): TestFixture {
{ id: "project-2", name: "Other project" }, { id: "project-2", name: "Other project" },
]) ])
.run(); .run();
const boards = new DistributionBoardRepository(context.db); const boards = new DistributionBoardFixtureRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections( const board = boards.createWithCircuitListAndDefaultSections(
"project-1", "project-1",
"UV-01" "UV-01"
@@ -8,7 +8,7 @@ import {
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } from "../src/db/database-context.js";
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.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 { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js"; import { circuits } from "../src/db/schema/circuits.js";
@@ -34,7 +34,7 @@ function createTestDatabase(): DatabaseContext {
]) ])
.run(); .run();
const boards = new DistributionBoardRepository(context.db); const boards = new DistributionBoardFixtureRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections( const board = boards.createWithCircuitListAndDefaultSections(
"project-1", "project-1",
"UV-01" "UV-01"
@@ -8,7 +8,7 @@ import {
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } from "../src/db/database-context.js";
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
@@ -36,7 +36,7 @@ function createTestDatabase(): DatabaseContext {
]) ])
.run(); .run();
const boards = new DistributionBoardRepository(context.db); const boards = new DistributionBoardFixtureRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections( const board = boards.createWithCircuitListAndDefaultSections(
"project-1", "project-1",
"UV-01" "UV-01"
+2 -2
View File
@@ -7,7 +7,7 @@ describe("circuit numbering service", () => {
const service = new CircuitNumberingService({ const service = new CircuitNumberingService({
sectionRepository: { sectionRepository: {
async findById() { async findById() {
return { id: "s1", prefix: "-2F" } as never; return { prefix: "-2F" };
}, },
}, },
circuitRepository: { circuitRepository: {
@@ -18,7 +18,7 @@ describe("circuit numbering service", () => {
{ equipmentIdentifier: "-2F5" }, { equipmentIdentifier: "-2F5" },
{ equipmentIdentifier: "-2FX" }, { equipmentIdentifier: "-2FX" },
{ equipmentIdentifier: "-1F9" }, { equipmentIdentifier: "-1F9" },
] as never[]; ];
}, },
}, },
}); });
@@ -8,7 +8,7 @@ import {
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } from "../src/db/database-context.js";
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-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 { projectChangeSets } from "../src/db/schema/project-change-sets.js"; import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
@@ -38,7 +38,7 @@ function createTestFixture(): TestFixture {
]) ])
.run(); .run();
const repository = new DistributionBoardRepository(context.db); const repository = new DistributionBoardFixtureRepository(context.db);
const board = repository.createWithCircuitListAndDefaultSections( const board = repository.createWithCircuitListAndDefaultSections(
"project-1", "project-1",
"UV-01" "UV-01"
@@ -8,7 +8,7 @@ import {
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } from "../src/db/database-context.js";
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
@@ -35,7 +35,7 @@ function createTestDatabase(): TestFixture {
{ id: "project-2", name: "Other project" }, { id: "project-2", name: "Other project" },
]) ])
.run(); .run();
const boards = new DistributionBoardRepository(context.db); const boards = new DistributionBoardFixtureRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections( const board = boards.createWithCircuitListAndDefaultSections(
"project-1", "project-1",
"UV-01" "UV-01"
@@ -8,7 +8,7 @@ import {
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } from "../src/db/database-context.js";
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
@@ -37,7 +37,7 @@ function createTestDatabase(): TestFixture {
{ id: "project-2", name: "Other project" }, { id: "project-2", name: "Other project" },
]) ])
.run(); .run();
const boards = new DistributionBoardRepository(context.db); const boards = new DistributionBoardFixtureRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections( const board = boards.createWithCircuitListAndDefaultSections(
"project-1", "project-1",
"UV-01" "UV-01"
@@ -8,7 +8,7 @@ import {
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } from "../src/db/database-context.js";
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
@@ -45,7 +45,7 @@ function createTestDatabase(): TestFixture {
]) ])
.run(); .run();
const boards = new DistributionBoardRepository(context.db); const boards = new DistributionBoardFixtureRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections( const board = boards.createWithCircuitListAndDefaultSections(
"project-1", "project-1",
"UV-01" "UV-01"
+77
View File
@@ -10,6 +10,15 @@ import {
verifyDatabaseFile, verifyDatabaseFile,
} from "../src/db/database-backup.js"; } from "../src/db/database-backup.js";
import { createDatabaseContext } from "../src/db/database-context.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"; import { projects } from "../src/db/schema/projects.js";
describe("database backup and restore", () => { 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, createDatabaseContext,
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } 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 { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
@@ -23,7 +23,7 @@ function createTestDatabase() {
migrationsFolder: path.resolve("src", "db", "migrations"), migrationsFolder: path.resolve("src", "db", "migrations"),
}); });
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run(); context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
const board = new DistributionBoardRepository( const board = new DistributionBoardFixtureRepository(
context.db context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01"); ).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const [section] = context.db 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",
});
});
});
+74 -2
View File
@@ -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 { 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 { 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 { 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 { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { 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 { 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 { 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 { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.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 { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
@@ -46,6 +49,12 @@ import {
createDistributionBoardInsertProjectCommand, createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot, createDistributionBoardStructureSnapshot,
} from "../src/domain/models/distribution-board-structure-project-command.model.js"; } 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 { 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 { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js"; import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
@@ -61,7 +70,7 @@ function createTestDatabase(): DatabaseContext {
.insert(projects) .insert(projects)
.values({ id: "project-1", name: "Test project" }) .values({ id: "project-1", name: "Test project" })
.run(); .run();
const board = new DistributionBoardRepository( const board = new DistributionBoardFixtureRepository(
context.db context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01"); ).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const section = context.db const section = context.db
@@ -105,6 +114,7 @@ function createService(context: DatabaseContext) {
new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitDeviceRowMoveProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db),
new DistributionBoardStructureProjectCommandRepository(context.db), new DistributionBoardStructureProjectCommandRepository(context.db),
new ProjectLocationStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectDeviceStructureProjectCommandRepository(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", () => { it("rejects unsupported and malformed commands before domain writes", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
@@ -7,7 +7,7 @@ import {
createDatabaseContext, createDatabaseContext,
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } 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 { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
@@ -37,7 +37,7 @@ function createTestDatabase(): TestFixture {
{ id: "project-2", name: "Other project" }, { id: "project-2", name: "Other project" },
]) ])
.run(); .run();
const boards = new DistributionBoardRepository(context.db); const boards = new DistributionBoardFixtureRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections( const board = boards.createWithCircuitListAndDefaultSections(
"project-1", "project-1",
"UV-01" "UV-01"
@@ -7,7 +7,7 @@ import {
createDatabaseContext, createDatabaseContext,
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } 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 { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
@@ -53,7 +53,7 @@ function createTestDatabase(): DatabaseContext {
{ id: "project-2", name: "Other project" }, { id: "project-2", name: "Other project" },
]) ])
.run(); .run();
const boards = new DistributionBoardRepository(context.db); const boards = new DistributionBoardFixtureRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections( const board = boards.createWithCircuitListAndDefaultSections(
"project-1", "project-1",
"UV-01" "UV-01"
+2 -2
View File
@@ -59,12 +59,12 @@ function createService() {
const service = new ProjectDeviceSyncService({ const service = new ProjectDeviceSyncService({
projectDeviceRepository: { projectDeviceRepository: {
async findById() { async findById() {
return projectDevice() as never; return projectDevice();
}, },
}, },
deviceRowRepository: { deviceRowRepository: {
async listLinkedByProjectDevice() { async listLinkedByProjectDevice() {
return rows as never; return rows;
}, },
}, },
}); });
+2 -2
View File
@@ -9,7 +9,7 @@ import {
} from "../src/db/database-context.js"; } from "../src/db/database-context.js";
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.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 { 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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
@@ -30,7 +30,7 @@ function createTestDatabase(): DatabaseContext {
.insert(projects) .insert(projects)
.values({ id: "project-1", name: "Test project" }) .values({ id: "project-1", name: "Test project" })
.run(); .run();
const board = new DistributionBoardRepository( const board = new DistributionBoardFixtureRepository(
context.db context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01"); ).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const section = context.db 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();
}
});
});
@@ -5,12 +5,11 @@ import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { import {
createDatabaseContext, createDatabaseContext,
type AppDatabase,
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } from "../src/db/database-context.js";
import { import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
ProjectRevisionConflictError, import { appendProjectRevision } from "../src/db/repositories/project-revision.persistence.js";
ProjectRevisionRepository,
} from "../src/db/repositories/project-revision.repository.js";
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
@@ -27,10 +26,11 @@ function createTestDatabase(): DatabaseContext {
} }
function appendTestRevision( function appendTestRevision(
repository: ProjectRevisionRepository, database: AppDatabase,
expectedRevision: number expectedRevision: number
) { ) {
return repository.append({ return database.transaction((tx) =>
appendProjectRevision(tx, {
projectId: "project-1", projectId: "project-1",
expectedRevision, expectedRevision,
source: "user", source: "user",
@@ -46,21 +46,30 @@ function appendTestRevision(
type: "circuit.update", type: "circuit.update",
payload: { circuitId: "circuit-1", displayName: "Alt" }, 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", () => { it("appends immutable revision metadata and payloads with a monotonic number", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
const repository = new ProjectRevisionRepository(context.db); assert.equal(getCurrentRevision(context.db, "project-1"), 0);
const appended = appendTestRevision(context.db, 0);
assert.equal(repository.getCurrentRevision("project-1"), 0);
const appended = appendTestRevision(repository, 0);
assert.equal(appended.projectId, "project-1"); assert.equal(appended.projectId, "project-1");
assert.equal(appended.revisionNumber, 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 const revision = context.db
.select() .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(next.revisionNumber, 2);
assert.equal(repository.getCurrentRevision("project-1"), 2); assert.equal(getCurrentRevision(context.db, "project-1"), 2);
} finally { } finally {
context.close(); context.close();
} }
@@ -116,18 +125,17 @@ describe("project revision repository", () => {
it("rejects a stale expected revision without writing history", () => { it("rejects a stale expected revision without writing history", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
const repository = new ProjectRevisionRepository(context.db); appendTestRevision(context.db, 0);
appendTestRevision(repository, 0);
assert.throws( assert.throws(
() => appendTestRevision(repository, 0), () => appendTestRevision(context.db, 0),
(error) => (error) =>
error instanceof ProjectRevisionConflictError && error instanceof ProjectRevisionConflictError &&
error.expectedRevision === 0 && error.expectedRevision === 0 &&
error.actualRevision === 1 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(projectRevisions).all().length, 1);
assert.equal(context.db.select().from(projectChangeSets).all().length, 1); assert.equal(context.db.select().from(projectChangeSets).all().length, 1);
} finally { } finally {
@@ -145,14 +153,12 @@ describe("project revision repository", () => {
SELECT RAISE(ABORT, 'forced change-set failure'); SELECT RAISE(ABORT, 'forced change-set failure');
END; END;
`); `);
const repository = new ProjectRevisionRepository(context.db);
assert.throws( assert.throws(
() => appendTestRevision(repository, 0), () => appendTestRevision(context.db, 0),
/forced change-set failure/ /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(projectRevisions).all().length, 0);
assert.equal(context.db.select().from(projectChangeSets).all().length, 0); assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
} finally { } finally {
@@ -163,7 +169,6 @@ describe("project revision repository", () => {
it("captures and retains automatic snapshots without deleting named snapshots", () => { it("captures and retains automatic snapshots without deleting named snapshots", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
const revisions = new ProjectRevisionRepository(context.db);
const snapshots = new ProjectSnapshotRepository(context.db); const snapshots = new ProjectSnapshotRepository(context.db);
const named = snapshots.createNamed({ const named = snapshots.createNamed({
projectId: "project-1", projectId: "project-1",
@@ -177,7 +182,7 @@ describe("project revision repository", () => {
expectedRevision < 325; expectedRevision < 325;
expectedRevision += 1 expectedRevision += 1
) { ) {
appendTestRevision(revisions, expectedRevision); appendTestRevision(context.db, expectedRevision);
} }
const stored = context.db const stored = context.db
@@ -234,13 +239,12 @@ describe("project revision repository", () => {
it("rolls back revision 25 when automatic snapshot persistence fails", () => { it("rolls back revision 25 when automatic snapshot persistence fails", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
const repository = new ProjectRevisionRepository(context.db);
for ( for (
let expectedRevision = 0; let expectedRevision = 0;
expectedRevision < 24; expectedRevision < 24;
expectedRevision += 1 expectedRevision += 1
) { ) {
appendTestRevision(repository, expectedRevision); appendTestRevision(context.db, expectedRevision);
} }
context.sqlite.exec(` context.sqlite.exec(`
CREATE TRIGGER fail_automatic_project_snapshot CREATE TRIGGER fail_automatic_project_snapshot
@@ -252,10 +256,10 @@ describe("project revision repository", () => {
`); `);
assert.throws( assert.throws(
() => appendTestRevision(repository, 24), () => appendTestRevision(context.db, 24),
/forced automatic snapshot failure/ /forced automatic snapshot failure/
); );
assert.equal(repository.getCurrentRevision("project-1"), 24); assert.equal(getCurrentRevision(context.db, "project-1"), 24);
assert.equal( assert.equal(
context.db.select().from(projectRevisions).all().length, context.db.select().from(projectRevisions).all().length,
24 24
+2 -2
View File
@@ -8,7 +8,7 @@ import {
createDatabaseContext, createDatabaseContext,
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } 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 { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js";
@@ -37,7 +37,7 @@ function createTestDatabase(): DatabaseContext {
threePhaseVoltageV: 400, threePhaseVoltageV: 400,
}) })
.run(); .run();
const board = new DistributionBoardRepository( const board = new DistributionBoardFixtureRepository(
context.db context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01"); ).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const list = context.db 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 { 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 { 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 { 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 { 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 { 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 { 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 { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js"; import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
@@ -49,7 +50,7 @@ function createTestDatabase(): DatabaseContext {
.insert(projects) .insert(projects)
.values({ id: "project-1", name: "Ausgangsprojekt" }) .values({ id: "project-1", name: "Ausgangsprojekt" })
.run(); .run();
const board = new DistributionBoardRepository( const board = new DistributionBoardFixtureRepository(
context.db context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01"); ).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const list = context.db const list = context.db
@@ -134,6 +135,7 @@ function createService(context: DatabaseContext) {
new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitDeviceRowMoveProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db),
new DistributionBoardStructureProjectCommandRepository(context.db), new DistributionBoardStructureProjectCommandRepository(context.db),
new ProjectLocationStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectDeviceStructureProjectCommandRepository(context.db), new ProjectDeviceStructureProjectCommandRepository(context.db),
@@ -192,7 +194,7 @@ function changeCompleteProjectState(context: DatabaseContext) {
simultaneityFactor: 1, simultaneityFactor: 1,
}) })
.run(); .run();
new DistributionBoardRepository( new DistributionBoardFixtureRepository(
context.db context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-02"); ).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;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["scripts/**/*.ts", "src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}