From 219da5ec3ba076467cfc08ca6659d9409f7222dc Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 2 Aug 2026 16:33:18 +0200 Subject: [PATCH] Persist Revit CSV configuration --- AGENTS.md | 15 +- docs/current-architecture.md | 26 +- .../spec/revit-csv-phase-14-audit-and-plan.md | 7 +- src/db/migrations/0002_awesome_madripoor.sql | 9 + src/db/migrations/meta/0002_snapshot.json | 1808 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + ...onfiguration-project-command.repository.ts | 105 + ...roject-state-restore-command.repository.ts | 12 + .../project-state-snapshot.persistence.ts | 7 + .../project-transfer.repository.ts | 7 + src/db/schema/external-csv-configurations.ts | 22 + ...csv-configuration-project-command.model.ts | 94 + .../models/project-state-snapshot.model.ts | 65 +- src/domain/models/project-transfer.model.ts | 8 + ...csv-configuration-project-command.store.ts | 19 + .../services/project-command.service.ts | 18 +- .../csv/external-csv-configuration.ts | 14 + .../composition/project-command-stores.ts | 6 +- ...uration-project-command.repository.test.ts | 226 +++ tests/external-csv-configuration.test.ts | 8 + tests/project-command.service.test.ts | 38 +- tests/project-state-snapshot.test.ts | 14 + 22 files changed, 2508 insertions(+), 27 deletions(-) create mode 100644 src/db/migrations/0002_awesome_madripoor.sql create mode 100644 src/db/migrations/meta/0002_snapshot.json create mode 100644 src/db/repositories/external-csv-configuration-project-command.repository.ts create mode 100644 src/db/schema/external-csv-configurations.ts create mode 100644 src/domain/models/external-csv-configuration-project-command.model.ts create mode 100644 src/domain/ports/external-csv-configuration-project-command.store.ts create mode 100644 tests/external-csv-configuration-project-command.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index c79c56c..b18ae4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,13 +250,20 @@ not change the project revision or undo/redo stacks. Restoring a server-stored snapshot verifies its checksum and the current-state hash, replaces supported project data atomically and records a new `restore` revision with a complete inverse command. Restore can therefore be undone and redone after a restart. -Current snapshot schema version 2 additionally stores whether the project is a -public building. Supported baseline version 1 snapshots are upgraded with -`isPublicBuilding = false`; pre-baseline snapshots remain unsupported. Both -versions contain circuit-group identity, distribution-board components and the +Current snapshot schema version 3 additionally stores the optional project-wide +Revit CSV configuration. Version 2 snapshots are upgraded with an empty external +configuration. Supported baseline version 1 snapshots are additionally upgraded +with `isPublicBuilding = false`; pre-baseline snapshots remain unsupported. All +supported versions contain circuit-group identity, distribution-board components and the separate circuit/component protection records. Portable duplicate imports remap component ids and protection-owner references together with the existing project graph. +Project-wide Revit CSV configuration uses +`external-csv-configuration.update`. Its complete expected/target snapshot is +persisted through the shared project-command transaction, so create, update, +delete, Undo and Redo are revision-safe. Snapshot schema 3, restore and portable +project duplication preserve the configuration; duplication remaps its internal +id and project link. The central revision boundary creates an automatic logical snapshot after each 25 new revisions and retains only the newest 12 automatic snapshots per project. Named snapshots are never removed by this retention policy. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 5e9ce1d..187d6ed 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -416,12 +416,14 @@ wieder her. verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`. -Snapshot-Schema 2 enthält zusätzlich die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor, +Snapshot-Schema 3 enthält zusätzlich die projektweite Revit-CSV-Konfiguration, +die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor, Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten 1:1-Schutzgerätedaten. Capture, benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide JSON-Importmodi verwenden denselben vollständigen Zustand. Die unterstützte -Baseline-Version 1 wird mit `isPublicBuilding = false` hochgestuft; Formate vor +Version 2 wird mit leerer externer Konfiguration hochgestuft. Die unterstützte +Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft; Formate vor dieser Baseline werden nicht eingelesen. Persistente Insert-, Update- und Delete-Commands versionieren Anlage, Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und @@ -473,7 +475,7 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. Ein triggergeführtes Register erzwingt bereits eine normalisierte, stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell - Snapshot-Schema 2. Persistente Insert/Delete/Update-Commands für + Snapshot-Schema 3. Persistente Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und @@ -485,10 +487,26 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter. +## Revit-CSV-Grundlage + +Der reine Adapter unter `src/external-model/csv` validiert die projektweite +Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine +klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt- +und Verdachtszeilen, prüft IFCGUIDs und serialisiert unveränderte Revit-Exporte +einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl +`npm run revit:verify-reference` prüft die bereitgestellte Referenzdatei. + +`external_csv_configurations` enthält höchstens eine versionierte Konfiguration +je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder +Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 3, +Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren +werden Konfigurations-UUID und Projektlink remapped. Importvorschau, persistente +externe Objekte und die Revit-GUI sind noch nicht implementiert. + ## Noch nicht unterstützt - Mehrbenutzerbetrieb und Konfliktauflösung -- Revit-/CSV-/IFCGUID-Round-trip +- vollständiger Revit-/CSV-/IFCGUID-Round-trip über die vorhandene Grundlage hinaus - vollständige elektrische Dimensionierung - Produktionsdeployment diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md index ad84c28..f677e26 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -4,8 +4,9 @@ Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 befindet sich in -Umsetzung; ihre reinen Transportverträge, Konfigurationsvalidatoren und die -zustandsfreie CSV-Transportgrundlage sind vorhanden. +Umsetzung; Transportverträge, Konfigurationsvalidatoren, die zustandsfreie +CSV-Transportgrundlage und die persistente projektweite Konfiguration sind +vorhanden. Fachliche Quelle bleibt [Revit-CSV-Integration – Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md). @@ -247,7 +248,7 @@ keine Projektrevision und laufen über einen eigenen Audit-Store. Titel-, Leer-, Objekt- und Passthrough-Zeilen implementieren. **Erledigt für synthetische Testdaten; die Referenzdatei steht noch aus.** 3. Konfiguration über Migration, Snapshot v3, Transfer und den Command - `external-csv-configuration.update` persistieren. + `external-csv-configuration.update` persistieren. **Erledigt.** 4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. Der Server speichert keinen Entwurf: Bei Bestätigung wird die Datei erneut übertragen, erneut geparst und ihr Hash gegen die Vorschau geprüft. diff --git a/src/db/migrations/0002_awesome_madripoor.sql b/src/db/migrations/0002_awesome_madripoor.sql new file mode 100644 index 0000000..f84f75e --- /dev/null +++ b/src/db/migrations/0002_awesome_madripoor.sql @@ -0,0 +1,9 @@ +CREATE TABLE `external_csv_configurations` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `configuration_version` integer NOT NULL, + `configuration` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `external_csv_configurations_project_id_unique` ON `external_csv_configurations` (`project_id`); \ No newline at end of file diff --git a/src/db/migrations/meta/0002_snapshot.json b/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..17fb94a --- /dev/null +++ b/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,1808 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "45d3523a-a37a-4c68-8fa1-abd6585108ab", + "prevId": "62320e94-1976-4288-8640-d37cfc07f1ee", + "tables": { + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_list_equipment_identifiers": { + "name": "circuit_list_equipment_identifiers", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_list_equipment_identifiers_owner_unique": { + "name": "circuit_list_equipment_identifiers_owner_unique", + "columns": [ + "owner_type", + "owner_id" + ], + "isUnique": true + }, + "circuit_list_equipment_identifiers_list_identifier_unique": { + "name": "circuit_list_equipment_identifiers_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_list_equipment_identifiers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_protection_devices": { + "name": "circuit_protection_devices", + "columns": { + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_protection_devices_circuit_id_circuits_id_fk": { + "name": "circuit_protection_devices_circuit_id_circuits_id_fk", + "tableFrom": "circuit_protection_devices", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_number": { + "name": "group_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + }, + "circuit_sections_list_category_group_unique": { + "name": "circuit_sections_list_category_group_unique", + "columns": [ + "circuit_list_id", + "category", + "group_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_component_protection_devices": { + "name": "distribution_board_component_protection_devices", + "columns": { + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { + "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", + "tableFrom": "distribution_board_component_protection_devices", + "tableTo": "distribution_board_components", + "columnsFrom": [ + "component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_components": { + "name": "distribution_board_components", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "distribution_board_components_list_identifier_unique": { + "name": "distribution_board_components_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + }, + "distribution_board_components_section_role_unique": { + "name": "distribution_board_components_section_role_unique", + "columns": [ + "section_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { + "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_board_components_section_id_circuit_sections_id_fk": { + "name": "distribution_board_components_section_id_circuit_sections_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supply_type": { + "name": "supply_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_boards_project_id_projects_id_fk": { + "name": "distribution_boards_project_id_projects_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_boards_floor_id_floors_id_fk": { + "name": "distribution_boards_floor_id_floors_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_csv_configurations": { + "name": "external_csv_configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_csv_configurations_project_id_unique": { + "name": "external_csv_configurations_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_csv_configurations_project_id_projects_id_fk": { + "name": "external_csv_configurations_project_id_projects_id_fk", + "tableFrom": "external_csv_configurations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "floors": { + "name": "floors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_project_number": { + "name": "internal_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_number": { + "name": "external_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "building_owner": { + "name": "building_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public_building": { + "name": "is_public_building", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "single_phase_voltage_v": { + "name": "single_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 230 + }, + "three_phase_voltage_v": { + "name": "three_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 400 + }, + "enabled_distribution_board_supply_types": { + "name": "enabled_distribution_board_supply_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" + }, + "current_revision": { + "name": "current_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number": { + "name": "room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_project_id_projects_id_fk": { + "name": "rooms_project_id_projects_id_fk", + "tableFrom": "rooms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 63252a5..d1e3295 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1785511894897, "tag": "0001_add_public_building_setting", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1785680771603, + "tag": "0002_awesome_madripoor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/external-csv-configuration-project-command.repository.ts b/src/db/repositories/external-csv-configuration-project-command.repository.ts new file mode 100644 index 0000000..c7d7bf7 --- /dev/null +++ b/src/db/repositories/external-csv-configuration-project-command.repository.ts @@ -0,0 +1,105 @@ +import { eq } from "drizzle-orm"; +import { + assertExternalCsvConfigurationUpdateProjectCommand, + createExternalCsvConfigurationUpdateProjectCommand, + type ExternalCsvConfigurationSnapshot, +} from "../../domain/models/external-csv-configuration-project-command.model.js"; +import type { + ExecuteExternalCsvConfigurationCommandInput, + ExternalCsvConfigurationProjectCommandStore, +} from "../../domain/ports/external-csv-configuration-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; +import { projects } from "../schema/projects.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; + +export class ExternalCsvConfigurationProjectCommandRepository + implements ExternalCsvConfigurationProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteExternalCsvConfigurationCommandInput) { + assertExternalCsvConfigurationUpdateProjectCommand(input.command); + return executeProjectCommandTransaction(this.database, input, (tx) => { + const project = tx + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + if (!project) { + throw new Error("Project not found."); + } + const current = tx + .select() + .from(externalCsvConfigurations) + .where(eq(externalCsvConfigurations.projectId, input.projectId)) + .get() ?? null; + assertSameSnapshot(current, input.command.payload.expected); + const target = input.command.payload.target; + if (target !== null && target.projectId !== input.projectId) { + throw new Error("External CSV configuration belongs to a different project."); + } + if (sameSnapshot(current, target)) { + throw new Error("External CSV configuration did not change."); + } + if ( + input.source === "user" && + target !== null && + target.configurationVersion !== (current?.configurationVersion ?? 0) + 1 + ) { + throw new Error("External CSV configuration version must advance by one."); + } + + if (target === null) { + tx.delete(externalCsvConfigurations) + .where(eq(externalCsvConfigurations.projectId, input.projectId)) + .run(); + } else if (current === null) { + tx.insert(externalCsvConfigurations).values(target).run(); + } else { + const updated = tx + .update(externalCsvConfigurations) + .set({ + configurationVersion: target.configurationVersion, + configuration: target.configuration, + }) + .where(eq(externalCsvConfigurations.id, current.id)) + .run(); + if (updated.changes !== 1) { + throw new Error("External CSV configuration changed before update."); + } + } + return createExternalCsvConfigurationUpdateProjectCommand(target, current); + }); + } +} + +function assertSameSnapshot( + current: ExternalCsvConfigurationSnapshot | null, + expected: ExternalCsvConfigurationSnapshot | null +) { + if (!sameSnapshot(current, expected)) { + throw new Error("External CSV configuration changed before update."); + } +} + +function sameSnapshot( + left: ExternalCsvConfigurationSnapshot | null, + right: ExternalCsvConfigurationSnapshot | null +) { + return canonicalJson(left) === canonicalJson(right); +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (value !== null && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/src/db/repositories/project-state-restore-command.repository.ts b/src/db/repositories/project-state-restore-command.repository.ts index e2fde68..34422b3 100644 --- a/src/db/repositories/project-state-restore-command.repository.ts +++ b/src/db/repositories/project-state-restore-command.repository.ts @@ -26,6 +26,7 @@ import { projectDevices } from "../schema/project-devices.js"; import { projectChangeSets } from "../schema/project-change-sets.js"; import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; +import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { readProjectStateSnapshot, @@ -161,6 +162,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) { projectDevices: byId(state.projectDevices), floors: byId(state.floors), rooms: byId(state.rooms), + externalCsvConfiguration: state.externalCsvConfiguration, }); } @@ -183,6 +185,10 @@ function replaceProjectState( database: AppDatabase, state: ProjectStateSnapshot ) { + database + .delete(externalCsvConfigurations) + .where(eq(externalCsvConfigurations.projectId, state.project.id)) + .run(); database .delete(distributionBoards) .where(eq(distributionBoards.projectId, state.project.id)) @@ -218,6 +224,12 @@ function replaceProjectState( .run(); insertMany(database, floors, state.floors); + if (state.externalCsvConfiguration !== null) { + database + .insert(externalCsvConfigurations) + .values(state.externalCsvConfiguration) + .run(); + } insertMany(database, rooms, state.rooms); insertMany(database, projectDevices, state.projectDevices); insertMany(database, distributionBoards, state.distributionBoards); diff --git a/src/db/repositories/project-state-snapshot.persistence.ts b/src/db/repositories/project-state-snapshot.persistence.ts index bb5606d..03f35ab 100644 --- a/src/db/repositories/project-state-snapshot.persistence.ts +++ b/src/db/repositories/project-state-snapshot.persistence.ts @@ -19,6 +19,7 @@ import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; +import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; export interface PersistedProjectStateSnapshot { currentRevision: number; @@ -59,6 +60,11 @@ export function readProjectStateSnapshot( .where(eq(distributionBoards.projectId, projectId)) .orderBy(asc(distributionBoards.name), asc(distributionBoards.id)) .all(); + const externalCsvConfiguration = database + .select() + .from(externalCsvConfigurations) + .where(eq(externalCsvConfigurations.projectId, projectId)) + .get() ?? null; const listRows = database .select() .from(circuitLists) @@ -241,6 +247,7 @@ export function readProjectStateSnapshot( project.enabledDistributionBoardSupplyTypes, }, distributionBoards: boardRows, + externalCsvConfiguration, circuitLists: listRows, circuitSections: sectionRows, distributionBoardComponents: componentRows, diff --git a/src/db/repositories/project-transfer.repository.ts b/src/db/repositories/project-transfer.repository.ts index 6f927cc..8bb7274 100644 --- a/src/db/repositories/project-transfer.repository.ts +++ b/src/db/repositories/project-transfer.repository.ts @@ -20,6 +20,7 @@ import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; +import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; import { hashProjectStatePayload, readProjectStateSnapshot, @@ -116,6 +117,12 @@ function insertProjectState( .insert(projects) .values({ ...state.project, currentRevision: 0 }) .run(); + if (state.externalCsvConfiguration !== null) { + database + .insert(externalCsvConfigurations) + .values(state.externalCsvConfiguration) + .run(); + } insertMany(database, floors, state.floors); insertMany(database, rooms, state.rooms); insertMany(database, projectDevices, state.projectDevices); diff --git a/src/db/schema/external-csv-configurations.ts b/src/db/schema/external-csv-configurations.ts new file mode 100644 index 0000000..2cbcd06 --- /dev/null +++ b/src/db/schema/external-csv-configurations.ts @@ -0,0 +1,22 @@ +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js"; +import { projects } from "./projects.js"; + +export const externalCsvConfigurations = sqliteTable( + "external_csv_configurations", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + configurationVersion: integer("configuration_version").notNull(), + configuration: text("configuration", { mode: "json" }) + .$type() + .notNull(), + }, + (table) => [ + uniqueIndex("external_csv_configurations_project_id_unique").on( + table.projectId + ), + ] +); diff --git a/src/domain/models/external-csv-configuration-project-command.model.ts b/src/domain/models/external-csv-configuration-project-command.model.ts new file mode 100644 index 0000000..bb87342 --- /dev/null +++ b/src/domain/models/external-csv-configuration-project-command.model.ts @@ -0,0 +1,94 @@ +import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js"; +import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const externalCsvConfigurationUpdateCommandType = + "external-csv-configuration.update" as const; +export const externalCsvConfigurationCommandSchemaVersion = 1 as const; + +export interface ExternalCsvConfigurationSnapshot { + id: string; + projectId: string; + configurationVersion: number; + configuration: ExternalCsvConfiguration; +} + +export interface ExternalCsvConfigurationUpdatePayload { + expected: ExternalCsvConfigurationSnapshot | null; + target: ExternalCsvConfigurationSnapshot | null; +} + +export interface ExternalCsvConfigurationUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof externalCsvConfigurationCommandSchemaVersion; + type: typeof externalCsvConfigurationUpdateCommandType; +} + +export function createExternalCsvConfigurationUpdateProjectCommand( + expected: ExternalCsvConfigurationSnapshot | null, + target: ExternalCsvConfigurationSnapshot | null +): ExternalCsvConfigurationUpdateProjectCommand { + const command: ExternalCsvConfigurationUpdateProjectCommand = { + schemaVersion: externalCsvConfigurationCommandSchemaVersion, + type: externalCsvConfigurationUpdateCommandType, + payload: { expected, target }, + }; + assertExternalCsvConfigurationUpdateProjectCommand(command); + return command; +} + +export function assertExternalCsvConfigurationUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is ExternalCsvConfigurationUpdateProjectCommand { + if ( + command.schemaVersion !== externalCsvConfigurationCommandSchemaVersion || + command.type !== externalCsvConfigurationUpdateCommandType || + !isRecord(command.payload) + ) { + throw new Error("Unsupported external CSV configuration update command."); + } + assertSnapshot(command.payload.expected, "expected"); + assertSnapshot(command.payload.target, "target"); + if (command.payload.expected === null && command.payload.target === null) { + throw new Error("External CSV configuration command must change state."); + } + if ( + command.payload.expected !== null && + command.payload.target !== null && + (command.payload.expected.id !== command.payload.target.id || + command.payload.expected.projectId !== command.payload.target.projectId) + ) { + throw new Error("External CSV configuration identity must remain stable."); + } +} + +function assertSnapshot( + value: unknown, + field: string +): asserts value is ExternalCsvConfigurationSnapshot | null { + if (value === null) { + return; + } + if (!isRecord(value)) { + throw new Error(`${field} external CSV configuration must be an object or null.`); + } + assertId(value.id, `${field}.id`); + assertId(value.projectId, `${field}.projectId`); + if (!Number.isSafeInteger(value.configurationVersion) || (value.configurationVersion as number) < 1) { + throw new Error(`${field}.configurationVersion must be a positive integer.`); + } + assertExternalCsvConfiguration(value.configuration); + if (Object.keys(value).length !== 4) { + throw new Error(`${field} external CSV configuration contains unknown fields.`); + } +} + +function assertId(value: unknown, field: string) { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${field} must be a non-empty string.`); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/models/project-state-snapshot.model.ts b/src/domain/models/project-state-snapshot.model.ts index 86d84c4..8dc2e7e 100644 --- a/src/domain/models/project-state-snapshot.model.ts +++ b/src/domain/models/project-state-snapshot.model.ts @@ -18,9 +18,12 @@ import { resolveCircuitPhaseType, resolveProjectVoltage, } from "../services/project-voltage.service.js"; +import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js"; +import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js"; -export const projectStateSnapshotSchemaVersion = 2 as const; -const previousProjectStateSnapshotSchemaVersion = 1 as const; +export const projectStateSnapshotSchemaVersion = 3 as const; +const previousProjectStateSnapshotSchemaVersion = 2 as const; +const baselineProjectStateSnapshotSchemaVersion = 1 as const; const idSchema = z.string().trim().min(1); const nullableStringSchema = z.string().nullable(); @@ -249,6 +252,17 @@ const componentProtectionDeviceSchema = z .strict() .superRefine(validatePersistedProtectionDevice); +const externalCsvConfigurationSnapshotSchema = z + .object({ + id: idSchema, + projectId: idSchema, + configurationVersion: z.number().int().positive(), + configuration: z.custom( + (value) => validateExternalCsvConfiguration(value).success + ), + }) + .strict(); + const projectStateSnapshotContents = { circuitLists: z.array(circuitListSchema), circuitSections: z.array(circuitSectionSchema), @@ -268,6 +282,7 @@ export const projectStateSnapshotSchema = z schemaVersion: z.literal(projectStateSnapshotSchemaVersion), project: projectSchema, distributionBoards: z.array(distributionBoardSchema), + externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), ...projectStateSnapshotContents, }) .strict(); @@ -275,6 +290,15 @@ export const projectStateSnapshotSchema = z const previousProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion), + project: projectSchema, + distributionBoards: z.array(distributionBoardSchema), + ...projectStateSnapshotContents, + }) + .strict(); + +const baselineProjectStateSnapshotSchema = z + .object({ + schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion), project: previousProjectSchema, distributionBoards: z.array(distributionBoardSchema), ...projectStateSnapshotContents, @@ -301,20 +325,29 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { value === null || typeof value !== "object" || Array.isArray(value) || - (value as { schemaVersion?: unknown }).schemaVersion !== - previousProjectStateSnapshotSchemaVersion + typeof (value as { schemaVersion?: unknown }).schemaVersion !== "number" ) { return value; } - const previous = previousProjectStateSnapshotSchema.parse(value); - return { - ...previous, - schemaVersion: projectStateSnapshotSchemaVersion, - project: { - ...previous.project, - isPublicBuilding: false, - }, - }; + const schemaVersion = (value as { schemaVersion: number }).schemaVersion; + if (schemaVersion === previousProjectStateSnapshotSchemaVersion) { + const previous = previousProjectStateSnapshotSchema.parse(value); + return { + ...previous, + schemaVersion: projectStateSnapshotSchemaVersion, + externalCsvConfiguration: null, + }; + } + if (schemaVersion === baselineProjectStateSnapshotSchemaVersion) { + const baseline = baselineProjectStateSnapshotSchema.parse(value); + return { + ...baseline, + schemaVersion: projectStateSnapshotSchemaVersion, + project: { ...baseline.project, isPublicBuilding: false }, + externalCsvConfiguration: null, + }; + } + return value; } function normalizeSnapshotPhaseTypes( @@ -377,6 +410,12 @@ function assertProjectStateSnapshotRelations( snapshot: ProjectStateSnapshot ) { const projectId = snapshot.project.id; + if ( + snapshot.externalCsvConfiguration !== null && + snapshot.externalCsvConfiguration.projectId !== projectId + ) { + throw new Error("Snapshot external CSV configuration belongs to a different project."); + } const boardIds = uniqueIds( snapshot.distributionBoards, "distribution board" diff --git a/src/domain/models/project-transfer.model.ts b/src/domain/models/project-transfer.model.ts index 47c8804..1282ef6 100644 --- a/src/domain/models/project-transfer.model.ts +++ b/src/domain/models/project-transfer.model.ts @@ -94,6 +94,14 @@ export function remapProjectState( return parseProjectStateSnapshot({ ...source, project: { ...source.project, id: targetProjectId, name }, + externalCsvConfiguration: + source.externalCsvConfiguration === null + ? null + : { + ...source.externalCsvConfiguration, + id: createId(), + projectId: targetProjectId, + }, distributionBoards: source.distributionBoards.map((board) => ({ ...board, id: requiredId(boardIds, board.id), diff --git a/src/domain/ports/external-csv-configuration-project-command.store.ts b/src/domain/ports/external-csv-configuration-project-command.store.ts new file mode 100644 index 0000000..0bc74b4 --- /dev/null +++ b/src/domain/ports/external-csv-configuration-project-command.store.ts @@ -0,0 +1,19 @@ +import type { ExternalCsvConfigurationUpdateProjectCommand } from "../models/external-csv-configuration-project-command.model.js"; +import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; + +export interface ExecuteExternalCsvConfigurationCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: ExternalCsvConfigurationUpdateProjectCommand; +} + +export interface ExternalCsvConfigurationProjectCommandStore { + execute(input: ExecuteExternalCsvConfigurationCommandInput): { + revision: AppendedProjectRevision; + inverse: ExternalCsvConfigurationUpdateProjectCommand; + }; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 65e8059..6900c88 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -161,6 +161,11 @@ import type { ProjectDeviceStructureProjectCommandStore } from "../ports/project import type { ProjectRevisionSource } from "../ports/project-revision.store.js"; import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js"; import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js"; +import { + assertExternalCsvConfigurationUpdateProjectCommand, + externalCsvConfigurationUpdateCommandType, +} from "../models/external-csv-configuration-project-command.model.js"; +import type { ExternalCsvConfigurationProjectCommandStore } from "../ports/external-csv-configuration-project-command.store.js"; interface DispatchProjectCommandInput { projectId: string; @@ -195,7 +200,8 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore, private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore, private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore, - private readonly historyStore: ProjectHistoryStore + private readonly historyStore: ProjectHistoryStore, + private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore ) {} executeUser( @@ -552,6 +558,16 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case externalCsvConfigurationUpdateCommandType: { + assertExternalCsvConfigurationUpdateProjectCommand(input.command); + if (!this.externalCsvConfigurationStore) { + throw new Error("External CSV configuration store is not available."); + } + return this.externalCsvConfigurationStore.execute({ + ...input, + command: input.command, + }).revision; + } case projectStateRestoreCommandType: { assertProjectStateRestoreCommand(input.command); return this.projectStateRestoreStore.execute({ diff --git a/src/external-model/csv/external-csv-configuration.ts b/src/external-model/csv/external-csv-configuration.ts index a06d7cf..47ffb55 100644 --- a/src/external-model/csv/external-csv-configuration.ts +++ b/src/external-model/csv/external-csv-configuration.ts @@ -27,6 +27,7 @@ export function assertExternalCsvConfiguration( if (!isRecord(value)) { throw new Error("External CSV configuration must be an object."); } + assertExactKeyCount(value, 9, "External CSV configuration"); if (value.schemaVersion !== externalCsvConfigurationSchemaVersion) { throw new Error("Unsupported external CSV configuration schema version."); } @@ -47,6 +48,7 @@ export function assertExternalCsvConfiguration( if (!isRecord(value.columns)) { throw new Error("External CSV columns must be an object."); } + assertExactKeyCount(value.columns, 8, "External CSV columns"); const mappedColumns = new Set(); for (const key of requiredColumnKeys) { const columnName = assertTrimmedString(value.columns[key], `columns.${key}`); @@ -74,6 +76,7 @@ export function assertExternalCsvConfiguration( if (!isRecord(mapping)) { throw new Error(`additionalSourceMappings.${index} must be an object.`); } + assertExactKeyCount(mapping, 2, `additionalSourceMappings.${index}`); const sourceColumn = assertTrimmedString( mapping.sourceColumn, `additionalSourceMappings.${index}.sourceColumn` @@ -100,6 +103,7 @@ export function assertExternalCsvConfiguration( if (!isRecord(rule)) { throw new Error(`familyTypeRules.${index} must be an object.`); } + assertExactKeyCount(rule, 6, `familyTypeRules.${index}`); const exactFamilyAndType = assertTrimmedString( rule.exactFamilyAndType, `familyTypeRules.${index}.exactFamilyAndType` @@ -152,9 +156,11 @@ function assertQuantityRule(value: unknown, index: number): asserts value is Ext throw new Error(`familyTypeRules.${index}.quantityRule must be an object.`); } if (value.kind === "mapped-column") { + assertExactKeyCount(value, 1, `familyTypeRules.${index}.quantityRule`); return; } if (value.kind === "fixed") { + assertExactKeyCount(value, 2, `familyTypeRules.${index}.quantityRule`); assertPositiveFiniteNumber(value.quantity, `familyTypeRules.${index}.quantityRule.quantity`); return; } @@ -172,9 +178,11 @@ function assertDisplayNameSuggestion( throw new Error(`familyTypeRules.${index}.displayNameSuggestion must be an object or null.`); } if (value.kind === "selection-marker" || value.kind === "family-and-type") { + assertExactKeyCount(value, 1, `familyTypeRules.${index}.displayNameSuggestion`); return; } if (value.kind === "fixed") { + assertExactKeyCount(value, 2, `familyTypeRules.${index}.displayNameSuggestion`); assertTrimmedString(value.value, `familyTypeRules.${index}.displayNameSuggestion.value`); return; } @@ -197,3 +205,9 @@ function assertTrimmedString(value: unknown, field: string) { function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } + +function assertExactKeyCount(value: Record, count: number, field: string) { + if (Object.keys(value).length !== count) { + throw new Error(`${field} contains unknown or missing fields.`); + } +} diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index ef51bb9..c61838a 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -22,6 +22,7 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../../db/reposit import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js"; import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js"; +import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-configuration-project-command.repository.js"; export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); export const circuitDeviceRowProjectCommandStore = @@ -65,6 +66,8 @@ export const projectSettingsProjectCommandStore = export const projectStateRestoreCommandStore = new ProjectStateRestoreCommandRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db); +export const externalCsvConfigurationProjectCommandStore = + new ExternalCsvConfigurationProjectCommandRepository(db); export const projectCommandService = new ProjectCommandService( circuitProjectCommandStore, circuitDeviceRowProjectCommandStore, @@ -87,5 +90,6 @@ export const projectCommandService = new ProjectCommandService( circuitGroupMoveProjectCommandStore, circuitGroupSubtreeProjectCommandStore, circuitProtectionProjectCommandStore, - projectHistoryStore + projectHistoryStore, + externalCsvConfigurationProjectCommandStore ); diff --git a/tests/external-csv-configuration-project-command.repository.test.ts b/tests/external-csv-configuration-project-command.repository.test.ts new file mode 100644 index 0000000..c3e9c97 --- /dev/null +++ b/tests/external-csv-configuration-project-command.repository.test.ts @@ -0,0 +1,226 @@ +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 { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js"; +import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js"; +import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; +import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; +import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js"; +import { projects } from "../src/db/schema/projects.js"; +import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js"; +import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js"; +import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js"; +import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.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: "Projekt" }).run(); + return context; +} + +function snapshot(version = 1) { + const configuration = createDefaultExternalCsvConfiguration(externalCsvTestColumns); + configuration.familyTypeRules.push({ + exactFamilyAndType: "Steckdose: Standard", + internalDeviceType: "Steckdose", + connectionKind: null, + category: "single_phase", + quantityRule: { kind: "fixed", quantity: 1 }, + displayNameSuggestion: { kind: "selection-marker" }, + }); + return { + id: "external-config-1", + projectId: "project-1", + configurationVersion: version, + configuration, + }; +} + +describe("external CSV configuration project command", () => { + it("persists insert and update with exact inverse commands", () => { + const context = createTestDatabase(); + try { + const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db); + const initial = snapshot(); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + description: "Revit-CSV-Konfiguration anlegen", + command: createExternalCsvConfigurationUpdateProjectCommand(null, initial), + }); + assert.equal(inserted.revision.revisionNumber, 1); + assert.deepEqual(readConfiguration(context), initial); + assert.deepEqual(inserted.inverse.payload, { expected: initial, target: null }); + + const target = structuredClone(initial); + target.configurationVersion = 2; + target.configuration.decimalSeparator = "."; + const updated = repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createExternalCsvConfigurationUpdateProjectCommand(initial, target), + }); + assert.equal(updated.revision.revisionNumber, 2); + assert.deepEqual(readConfiguration(context), target); + assert.deepEqual(updated.inverse.payload, { expected: target, target: initial }); + } finally { + context.close(); + } + }); + + it("rejects stale state and cross-project targets without a partial revision", () => { + const context = createTestDatabase(); + try { + const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db); + const initial = snapshot(); + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createExternalCsvConfigurationUpdateProjectCommand(null, initial), + }); + + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createExternalCsvConfigurationUpdateProjectCommand(null, snapshot(2)), + }), + /changed before update/ + ); + const foreign = snapshot(2); + foreign.projectId = "project-2"; + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createExternalCsvConfigurationUpdateProjectCommand(initial, foreign), + }), + /identity must remain stable|different project/ + ); + assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, 1); + + const skippedVersion = snapshot(3); + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createExternalCsvConfigurationUpdateProjectCommand(initial, skippedVersion), + }), + /version must advance by one/ + ); + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createExternalCsvConfigurationUpdateProjectCommand(initial, initial), + }), + /did not change/ + ); + } finally { + context.close(); + } + }); + + it("includes configuration in snapshots and remaps it for project duplicates", () => { + const context = createTestDatabase(); + try { + const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db); + const initial = snapshot(); + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createExternalCsvConfigurationUpdateProjectCommand(null, initial), + }); + + const state = readProjectStateSnapshot(context.db, "project-1")?.state; + assert.deepEqual(state?.externalCsvConfiguration, initial); + const transferRepository = new ProjectTransferRepository(context.db); + const transfer = transferRepository.exportProject("project-1"); + assert.ok(transfer); + const duplicate = transferRepository.importDuplicate(transfer); + const duplicatedConfiguration = context.db + .select() + .from(externalCsvConfigurations) + .where(eq(externalCsvConfigurations.projectId, duplicate.projectId)) + .get(); + assert.ok(duplicatedConfiguration); + assert.notEqual(duplicatedConfiguration.id, initial.id); + assert.equal(duplicatedConfiguration.projectId, duplicate.projectId); + assert.deepEqual(duplicatedConfiguration.configuration, initial.configuration); + } finally { + context.close(); + } + }); + + it("restores and undoes the complete configuration through snapshot history", () => { + const context = createTestDatabase(); + try { + const configurationRepository = new ExternalCsvConfigurationProjectCommandRepository(context.db); + const initial = snapshot(); + configurationRepository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createExternalCsvConfigurationUpdateProjectCommand(null, initial), + }); + const configuredState = readProjectStateSnapshot(context.db, "project-1"); + assert.ok(configuredState); + configurationRepository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createExternalCsvConfigurationUpdateProjectCommand(initial, null), + }); + const emptyState = readProjectStateSnapshot(context.db, "project-1"); + assert.ok(emptyState); + + const restoreRepository = new ProjectStateRestoreCommandRepository(context.db); + const restored = restoreRepository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "user", + command: createProjectStateRestoreCommand( + emptyState.payloadSha256, + configuredState.state + ), + }); + assert.deepEqual(readConfiguration(context), initial); + + restoreRepository.execute({ + projectId: "project-1", + expectedRevision: 3, + source: "undo", + historyTargetChangeSetId: restored.revision.changeSetId, + command: restored.inverse, + }); + assert.equal(readConfiguration(context), null); + } finally { + context.close(); + } + }); +}); + +function readConfiguration(context: DatabaseContext) { + return context.db + .select() + .from(externalCsvConfigurations) + .where(eq(externalCsvConfigurations.projectId, "project-1")) + .get() ?? null; +} diff --git a/tests/external-csv-configuration.test.ts b/tests/external-csv-configuration.test.ts index 47143a7..7c75787 100644 --- a/tests/external-csv-configuration.test.ts +++ b/tests/external-csv-configuration.test.ts @@ -122,6 +122,14 @@ describe("external CSV configuration", () => { configuration.familyTypeRules[0].quantityRule = { kind: "mapped-column" }; assert.match(validateFailure(configuration), /requires a configured quantity column/); }); + + it("rejects unknown configuration fields", () => { + const configuration = { + ...createDefaultExternalCsvConfiguration(columns), + legacyMapping: true, + }; + assert.match(validateFailure(configuration), /unknown or missing fields/); + }); }); function validateFailure(value: unknown) { diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 9f4411e..545b3e1 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -30,6 +30,7 @@ import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/reposito import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js"; import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js"; +import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; @@ -42,6 +43,7 @@ import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; import { floors } from "../src/db/schema/floors.js"; import { rooms } from "../src/db/schema/rooms.js"; +import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js"; import { createCircuitProtectionUpdateProjectCommand } from "../src/domain/models/circuit-protection-project-command.model.js"; import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; @@ -85,6 +87,9 @@ import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/p import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js"; import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js"; import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js"; +import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js"; +import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js"; +import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; function createTestDatabase(): DatabaseContext { @@ -157,7 +162,8 @@ function createService(context: DatabaseContext) { new CircuitGroupMoveProjectCommandRepository(context.db), new CircuitGroupSubtreeProjectCommandRepository(context.db), new CircuitProtectionProjectCommandRepository(context.db), - new ProjectHistoryRepository(context.db) + new ProjectHistoryRepository(context.db), + new ExternalCsvConfigurationProjectCommandRepository(context.db) ); } @@ -1114,6 +1120,36 @@ describe("project command service", () => { } }); + it("dispatches external CSV configuration updates through persistent history", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const target = { + id: "external-config-1", + projectId: "project-1", + configurationVersion: 1, + configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns), + }; + const updated = service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createExternalCsvConfigurationUpdateProjectCommand(null, target), + }); + assert.equal(updated.history.currentRevision, 1); + assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1); + + const undone = service.undo({ projectId: "project-1", expectedRevision: 1 }); + assert.equal(undone.history.currentRevision, 2); + assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 0); + + const redone = service.redo({ projectId: "project-1", expectedRevision: 2 }); + assert.equal(redone.history.currentRevision, 3); + assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1); + } finally { + context.close(); + } + }); + it("dispatches distribution-board setup and its persisted inverse", () => { const context = createTestDatabase(); try { diff --git a/tests/project-state-snapshot.test.ts b/tests/project-state-snapshot.test.ts index 810d93d..61dc8bd 100644 --- a/tests/project-state-snapshot.test.ts +++ b/tests/project-state-snapshot.test.ts @@ -31,6 +31,7 @@ function minimalSnapshot() { ], }, distributionBoards: [], + externalCsvConfiguration: null, circuitLists: [], circuitSections: [], distributionBoardComponents: [], @@ -147,11 +148,24 @@ describe("project state snapshot model", () => { const baseline = structuredClone(minimalSnapshot()); baseline.schemaVersion = 1 as typeof baseline.schemaVersion; delete (baseline.project as Record).isPublicBuilding; + delete (baseline as Record).externalCsvConfiguration; const upgraded = parseProjectStateSnapshot(baseline); assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); assert.equal(upgraded.project.isPublicBuilding, false); + assert.equal(upgraded.externalCsvConfiguration, null); + }); + + it("upgrades snapshot version 2 with an empty external CSV configuration", () => { + const previous = structuredClone(minimalSnapshot()); + previous.schemaVersion = 2 as typeof previous.schemaVersion; + delete (previous as Record).externalCsvConfiguration; + + const upgraded = parseProjectStateSnapshot(previous); + + assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); + assert.equal(upgraded.externalCsvConfiguration, null); }); it("rejects malformed baseline and unknown snapshot shapes", () => {