diff --git a/AGENTS.md b/AGENTS.md index a08ca7c..9418415 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -275,10 +275,20 @@ Phase 14.2 migration `0003` provides one implicit Revit CSV source per project, immutable import batches with original bytes plus the classified cell matrix, normalized source-room mappings and external objects unique by `(sourceId, ifcGuid)`. Source values, planning values and overrides are separate. -The current repository is read-only; no runtime import command writes these -tables yet. Snapshot/restore and portable project transfer schema version 4 +The general external-model repository is read-only; runtime writes belong only +to typed command adapters. Snapshot/restore and portable project transfer schema version 4 capture the complete external state and remap every internal UUID and link while preserving IFCGUIDs, source values, original bytes and the classified matrix. +Import batches additionally store the monotonic project CSV configuration +version introduced by additive migration `0004`; it is distinct from the CSV +configuration JSON schema version. +Confirmed initial state is written only through +`external-import.apply-initial`. The command rechecks configuration version, +original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit +planning overrides and internal project links in the shared transaction. It +never creates or links a CircuitDeviceRow. Its exact inverse removes the whole +unchanged external state, and Redo restores the same UUIDs and bytes. No public +apply endpoint or staging wizard invokes this command yet. 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 243503d..f5ea277 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -508,7 +508,9 @@ Migration `0003` ergänzt die relationale Grundlage für genau eine implizite `revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte. Batches halten Konfigurationssnapshot, Originalbytes und die klassifizierte -Zellmatrix zusammen. Quellwerte, lokale Planungswerte und Override-Metadaten +Zellmatrix zusammen. Migration `0004` ergänzt die monotone projektweite +Konfigurationsversion am Batch, getrennt von der Schema-Version des +Konfigurationsformats. Quellwerte, lokale Planungswerte und Override-Metadaten der Objekte sind getrennt; optionale Links auf Raum, Verteilung, ProjectDevice und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den vollständigen Zustand deterministisch und unterscheidet unbekannte von noch @@ -517,8 +519,17 @@ Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert. Snapshot/Transfer v4 erfasst Originalbytes, Matrix und alle internen Links, stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben -unverändert. Import-Command und bestätigte Importübernahme sind noch nicht -implementiert; bloße Vorschau erzeugt weiterhin keine dieser Zeilen. +unverändert. `external-import.apply-initial` ist die atomare Schreibgrenze für +einen bestätigten Erstimport. Vor dem Insert prüft sie den aktuellen +Konfigurationsstand, SHA-256 und Base64-Kanonizität, parst die Originalbytes +erneut, vergleicht Matrix, vollständige IFCGUID-Menge und Quellwerte und +validiert alle Raum-, Verteiler- und ProjectDevice-Links gegen das Projekt. +Nicht explizit als Override markierte Planungswerte müssen dem regelbasierten +Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine +CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand, +Redo verwendet dieselben UUIDs und Bytes. Ein aufrufender Import-Endpunkt und +der mehrstufige Wizard sind noch nicht implementiert; bloße Vorschau erzeugt +weiterhin keine dieser Zeilen. `GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder ändern die Konfiguration; der PUT plant Identität und nächsten Konfigurationsstand serverseitig und verwendet den typisierten Command. 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 4570a5a..0388c3a 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -108,7 +108,8 @@ ohne eine zweite veränderbare Historientabelle einzuführen. - stabile UUID, `projectId`, `sourceId`, Importart und Zeitstempel; - Dateiname, SHA-256 und zugehörige Projektrevision; -- verwendeter Konfigurationssnapshot; +- verwendete monotone Konfigurationsversion und vollständiger + Konfigurationssnapshot; - Originaldatei als Bytes; - verlustarme logische Zellmatrix mit Zeilenklassen und Dialektmetadaten. @@ -278,6 +279,14 @@ einen Commit aufgenommen. Matrix und sämtliche internen Links. Duplikate remappen alle internen UUIDs, lassen IFCGUID und Quelltransport jedoch unverändert. 3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen. + **Erledigt:** `external-import.apply-initial` wechselt ausschließlich + zwischen leerem und vollständig bestätigtem externem Zustand. Er prüft + Konfigurationsversion, Original-Hash, erneut geparste Matrix, vollständige + IFCGUID-Menge, Quellwerte, explizite Overrides und projektinterne Links. + Der Command verbietet CircuitDeviceRow-Links, ist ein atomarer Undo-Schritt + und verwendet für Redo dieselben UUIDs und Bytes. Migration `0004` ergänzt + die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003` + bleibt unverändert. 4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. diff --git a/src/db/migrations/0004_illegal_the_stranger.sql b/src/db/migrations/0004_illegal_the_stranger.sql new file mode 100644 index 0000000..6588888 --- /dev/null +++ b/src/db/migrations/0004_illegal_the_stranger.sql @@ -0,0 +1 @@ +ALTER TABLE `external_import_batches` ADD `configuration_version` integer NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/meta/0004_snapshot.json b/src/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..57c5292 --- /dev/null +++ b/src/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,2396 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "da7eb583-b86a-440c-a71e-babb5ee24cd1", + "prevId": "6012d737-97f7-420d-a44d-85edbe5e53cf", + "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": {} + }, + "external_import_batches": { + "name": "external_import_batches", + "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_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_kind": { + "name": "import_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at_iso": { + "name": "imported_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_project_revision": { + "name": "applied_project_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_snapshot": { + "name": "configuration_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_bytes": { + "name": "original_bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_import_batches_project_revision_idx": { + "name": "external_import_batches_project_revision_idx", + "columns": [ + "project_id", + "applied_project_revision" + ], + "isUnique": false + }, + "external_import_batches_source_imported_idx": { + "name": "external_import_batches_source_imported_idx", + "columns": [ + "source_id", + "imported_at_iso" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_import_batches_project_id_projects_id_fk": { + "name": "external_import_batches_project_id_projects_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_import_batches_source_id_external_model_sources_id_fk": { + "name": "external_import_batches_source_id_external_model_sources_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_objects": { + "name": "external_model_objects", + "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_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ifc_guid": { + "name": "ifc_guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_import_batch_id": { + "name": "last_seen_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accepted_import_batch_id": { + "name": "last_accepted_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_source_values": { + "name": "accepted_source_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planning_values": { + "name": "planning_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_room_mapping_id": { + "name": "external_room_mapping_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "presence_status": { + "name": "presence_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'present'" + } + }, + "indexes": { + "external_model_objects_source_ifc_guid_unique": { + "name": "external_model_objects_source_ifc_guid_unique", + "columns": [ + "source_id", + "ifc_guid" + ], + "isUnique": true + }, + "external_model_objects_project_presence_idx": { + "name": "external_model_objects_project_presence_idx", + "columns": [ + "project_id", + "presence_status" + ], + "isUnique": false + }, + "external_model_objects_distribution_board_idx": { + "name": "external_model_objects_distribution_board_idx", + "columns": [ + "distribution_board_id" + ], + "isUnique": false + }, + "external_model_objects_circuit_device_row_idx": { + "name": "external_model_objects_circuit_device_row_idx", + "columns": [ + "circuit_device_row_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_model_objects_project_id_projects_id_fk": { + "name": "external_model_objects_project_id_projects_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_source_id_external_model_sources_id_fk": { + "name": "external_model_objects_source_id_external_model_sources_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_seen_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_accepted_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk": { + "name": "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_room_mappings", + "columnsFrom": [ + "external_room_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_distribution_board_id_distribution_boards_id_fk": { + "name": "external_model_objects_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_linked_project_device_id_project_devices_id_fk": { + "name": "external_model_objects_linked_project_device_id_project_devices_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_sources": { + "name": "external_model_sources", + "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 + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'revit_csv'" + } + }, + "indexes": { + "external_model_sources_project_type_unique": { + "name": "external_model_sources_project_type_unique", + "columns": [ + "project_id", + "source_type" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_model_sources_project_id_projects_id_fk": { + "name": "external_model_sources_project_id_projects_id_fk", + "tableFrom": "external_model_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_room_mappings": { + "name": "external_room_mappings", + "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_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_source_room_key": { + "name": "normalized_source_room_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_floor_name": { + "name": "source_floor_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_room_number": { + "name": "source_room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_room_name": { + "name": "source_room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_distribution_board_id": { + "name": "default_distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_room_mappings_source_key_unique": { + "name": "external_room_mappings_source_key_unique", + "columns": [ + "source_id", + "normalized_source_room_key" + ], + "isUnique": true + }, + "external_room_mappings_project_room_idx": { + "name": "external_room_mappings_project_room_idx", + "columns": [ + "project_id", + "room_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_room_mappings_project_id_projects_id_fk": { + "name": "external_room_mappings_project_id_projects_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_source_id_external_model_sources_id_fk": { + "name": "external_room_mappings_source_id_external_model_sources_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_room_id_rooms_id_fk": { + "name": "external_room_mappings_room_id_rooms_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk": { + "name": "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "distribution_boards", + "columnsFrom": [ + "default_distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 1ac144a..e3f76b7 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1785683710155, "tag": "0003_outstanding_maddog", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1785684457208, + "tag": "0004_illegal_the_stranger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/external-initial-import-project-command.repository.ts b/src/db/repositories/external-initial-import-project-command.repository.ts new file mode 100644 index 0000000..9d2895a --- /dev/null +++ b/src/db/repositories/external-initial-import-project-command.repository.ts @@ -0,0 +1,250 @@ +import crypto from "node:crypto"; +import { eq } from "drizzle-orm"; +import { + assertExternalInitialImportProjectCommand, + createExternalInitialImportProjectCommand, + isEmptyExternalModelState, +} from "../../domain/models/external-initial-import-project-command.model.js"; +import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js"; +import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js"; +import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js"; +import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import { + createExternalRoomKey, + indexExternalObjectsByIfcGuid, + projectInitialExternalObject, +} from "../../external-model/domain/external-model-matching.js"; +import type { AppDatabase } from "../database-context.js"; +import { distributionBoards } from "../schema/distribution-boards.js"; +import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; +import { externalImportBatches } from "../schema/external-import-batches.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; +import { externalModelSources } from "../schema/external-model-sources.js"; +import { externalRoomMappings } from "../schema/external-room-mappings.js"; +import { projectDevices } from "../schema/project-devices.js"; +import { rooms } from "../schema/rooms.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { ExternalModelStateRepository } from "./external-model-state.repository.js"; + +export class ExternalInitialImportProjectCommandRepository + implements ExternalInitialImportProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: Parameters[0]) { + assertExternalInitialImportProjectCommand(input.command); + return executeProjectCommandTransaction(this.database, input, (tx) => { + const currentResult = new ExternalModelStateRepository(tx) + .getByProject(input.projectId); + if (!currentResult.projectExists) throw new Error("Project not found."); + const expected = input.command.payload.expected; + if (!sameState(currentResult.state, expected)) { + throw new Error("External model state changed before initial import."); + } + const target = input.command.payload.target; + if (!isEmptyExternalModelState(target)) { + validatePopulatedTarget(tx, input, target); + } + + replaceExternalState(tx, input.projectId, target); + const persisted = new ExternalModelStateRepository(tx) + .getByProject(input.projectId).state; + if (!sameState(persisted, target)) { + throw new Error("Persisted external initial import differs from its target."); + } + return createExternalInitialImportProjectCommand(target, currentResult.state); + }); + } +} + +function validatePopulatedTarget( + database: AppDatabase, + input: Parameters[0], + target: ExternalModelStateSnapshot +) { + const source = target.source!; + const batch = target.importBatches[0]; + if (source.projectId !== input.projectId) { + throw new Error("External initial import belongs to a different project."); + } + if ( + input.source === "user" && + batch.appliedProjectRevision !== input.expectedRevision + 1 + ) { + throw new Error("External initial import revision is invalid."); + } + const configuration = database + .select() + .from(externalCsvConfigurations) + .where(eq(externalCsvConfigurations.projectId, input.projectId)) + .get(); + if ( + !configuration || + configuration.configurationVersion !== batch.configurationVersion || + canonicalJson(configuration.configuration) !== + canonicalJson(batch.configurationSnapshot) + ) { + throw new Error("External initial import configuration changed before apply."); + } + + const bytes = decodeCanonicalBase64(batch.originalContentBase64); + const sha256 = crypto.createHash("sha256").update(bytes).digest("hex"); + if (sha256 !== batch.sha256) { + throw new Error("External initial import checksum is invalid."); + } + const parsedDocument = parseExternalCsv(bytes, batch.configurationSnapshot); + if (canonicalJson(parsedDocument) !== canonicalJson(batch.document)) { + throw new Error("External initial import document differs from the original bytes."); + } + const preview = createExternalCsvPreview({ + fileName: batch.fileName, + bytes, + configuration: batch.configurationSnapshot, + }); + const targetByIfcGuid = indexExternalObjectsByIfcGuid(target.objects); + if (targetByIfcGuid.size !== preview.objects.length) { + throw new Error("External initial import must contain every CSV object exactly once."); + } + const mappingsById = new Map( + target.roomMappings.map((mapping) => [mapping.id, mapping]) + ); + const expectedRoomKeys = new Set(); + for (const candidate of preview.objects) { + const object = targetByIfcGuid.get(candidate.ifcGuid); + if (!object) { + throw new Error(`External initial import is missing IFCGUID ${candidate.ifcGuid}.`); + } + const projected = projectInitialExternalObject( + candidate, + batch.configurationSnapshot + ); + if (canonicalJson(object.acceptedSourceValues) !== canonicalJson(projected.sourceValues)) { + throw new Error("External initial import source values differ from the CSV."); + } + for (const field of Object.keys(projected.planningValues) as Array) { + if ( + !object.overriddenFields.includes(field) && + canonicalJson(object.planningValues[field]) !== + canonicalJson(projected.planningValues[field]) + ) { + throw new Error(`External initial planning field ${field} changed without an override.`); + } + } + const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName); + if (roomKey === null) { + if (object.externalRoomMappingId !== null) { + throw new Error("External object without a source room must not use a room mapping."); + } + } else { + expectedRoomKeys.add(roomKey); + const mapping = object.externalRoomMappingId === null + ? null + : mappingsById.get(object.externalRoomMappingId); + if (!mapping || mapping.normalizedSourceRoomKey !== roomKey) { + throw new Error("External object source room mapping is incomplete."); + } + } + } + if ( + target.roomMappings.length !== expectedRoomKeys.size || + target.roomMappings.some( + (mapping) => !expectedRoomKeys.has(mapping.normalizedSourceRoomKey) + ) + ) { + throw new Error("External initial import room mappings do not match CSV rooms."); + } + validateInternalLinks(database, input.projectId, target); +} + +function validateInternalLinks( + database: AppDatabase, + projectId: string, + target: ExternalModelStateSnapshot +) { + const roomIds = new Set( + database.select({ id: rooms.id }).from(rooms) + .where(eq(rooms.projectId, projectId)).all().map((row) => row.id) + ); + const boardIds = new Set( + database.select({ id: distributionBoards.id }).from(distributionBoards) + .where(eq(distributionBoards.projectId, projectId)).all().map((row) => row.id) + ); + const deviceIds = new Set( + database.select({ id: projectDevices.id }).from(projectDevices) + .where(eq(projectDevices.projectId, projectId)).all().map((row) => row.id) + ); + for (const mapping of target.roomMappings) { + if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) { + throw new Error("External room mapping references another project."); + } + if ( + mapping.defaultDistributionBoardId !== null && + !boardIds.has(mapping.defaultDistributionBoardId) + ) { + throw new Error("External room mapping distribution board belongs to another project."); + } + } + for (const object of target.objects) { + if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) { + throw new Error("External object distribution board belongs to another project."); + } + if (object.linkedProjectDeviceId !== null && !deviceIds.has(object.linkedProjectDeviceId)) { + throw new Error("External object project device belongs to another project."); + } + } +} + +function replaceExternalState( + database: AppDatabase, + projectId: string, + target: ExternalModelStateSnapshot +) { + database.delete(externalModelSources) + .where(eq(externalModelSources.projectId, projectId)).run(); + if (target.source === null) return; + database.insert(externalModelSources).values(target.source).run(); + database.insert(externalImportBatches).values( + target.importBatches.map(({ originalContentBase64, ...batch }) => ({ + ...batch, + originalBytes: Buffer.from(originalContentBase64, "base64"), + })) + ).run(); + if (target.roomMappings.length) { + database.insert(externalRoomMappings).values(target.roomMappings).run(); + } + database.insert(externalModelObjects).values(target.objects).run(); +} + +function decodeCanonicalBase64(value: string) { + const bytes = Buffer.from(value, "base64"); + if (bytes.toString("base64") !== value) { + throw new Error("External initial import bytes are not canonical base64."); + } + return bytes; +} + +function sameState(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) { + return canonicalJson(sortState(left)) === canonicalJson(sortState(right)); +} + +function sortState(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot { + const byId = (entries: readonly T[]) => + [...entries].sort((left, right) => left.id.localeCompare(right.id)); + return { + source: state.source, + importBatches: byId(state.importBatches), + roomMappings: byId(state.roomMappings), + objects: byId(state.objects), + }; +} + +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/schema/external-import-batches.ts b/src/db/schema/external-import-batches.ts index bf317e9..125077e 100644 --- a/src/db/schema/external-import-batches.ts +++ b/src/db/schema/external-import-batches.ts @@ -22,6 +22,7 @@ export const externalImportBatches = sqliteTable( fileName: text("file_name").notNull(), sha256: text("sha256").notNull(), appliedProjectRevision: integer("applied_project_revision").notNull(), + configurationVersion: integer("configuration_version").notNull(), configurationSnapshot: text("configuration_snapshot", { mode: "json" }) .$type() .notNull(), diff --git a/src/domain/models/external-initial-import-project-command.model.ts b/src/domain/models/external-initial-import-project-command.model.ts new file mode 100644 index 0000000..2004cfc --- /dev/null +++ b/src/domain/models/external-initial-import-project-command.model.ts @@ -0,0 +1,134 @@ +import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const externalInitialImportCommandType = + "external-import.apply-initial" as const; +export const externalInitialImportCommandSchemaVersion = 1 as const; + +export interface ExternalInitialImportPayload { + expected: ExternalModelStateSnapshot; + target: ExternalModelStateSnapshot; +} + +export interface ExternalInitialImportProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof externalInitialImportCommandSchemaVersion; + type: typeof externalInitialImportCommandType; +} + +export function createExternalInitialImportProjectCommand( + expected: ExternalModelStateSnapshot, + target: ExternalModelStateSnapshot +): ExternalInitialImportProjectCommand { + const command: ExternalInitialImportProjectCommand = { + schemaVersion: externalInitialImportCommandSchemaVersion, + type: externalInitialImportCommandType, + payload: { expected, target }, + }; + assertExternalInitialImportProjectCommand(command); + return command; +} + +export function assertExternalInitialImportProjectCommand( + command: SerializedProjectCommand +): asserts command is ExternalInitialImportProjectCommand { + if ( + command.schemaVersion !== externalInitialImportCommandSchemaVersion || + command.type !== externalInitialImportCommandType || + !isRecord(command.payload) || + Object.keys(command.payload).length !== 2 + ) { + throw new Error("Unsupported external initial import command."); + } + const expected = parseExternalModelStateSnapshot(command.payload.expected); + const target = parseExternalModelStateSnapshot(command.payload.target); + const expectedIsEmpty = isEmptyExternalModelState(expected); + const targetIsEmpty = isEmptyExternalModelState(target); + if (expectedIsEmpty === targetIsEmpty) { + throw new Error("External initial import must transition between empty and populated state."); + } + assertPopulatedInitialState(expectedIsEmpty ? target : expected); +} + +export function createEmptyExternalModelState(): ExternalModelStateSnapshot { + return { source: null, importBatches: [], roomMappings: [], objects: [] }; +} + +export function isEmptyExternalModelState(state: ExternalModelStateSnapshot) { + return ( + state.source === null && + state.importBatches.length === 0 && + state.roomMappings.length === 0 && + state.objects.length === 0 + ); +} + +function assertPopulatedInitialState(state: ExternalModelStateSnapshot) { + if (state.source === null || state.importBatches.length !== 1 || state.objects.length === 0) { + throw new Error("External initial import requires one source, one batch and at least one object."); + } + const source = state.source; + const batch = state.importBatches[0]; + if ( + batch.importKind !== "initial" || + batch.projectId !== source.projectId || + batch.sourceId !== source.id + ) { + throw new Error("External initial import batch identity is invalid."); + } + const mappingIds = uniqueIds(state.roomMappings, "room mapping"); + const roomKeys = new Set(); + for (const mapping of state.roomMappings) { + if (mapping.projectId !== source.projectId || mapping.sourceId !== source.id) { + throw new Error("External initial room mapping identity is invalid."); + } + if (roomKeys.has(mapping.normalizedSourceRoomKey)) { + throw new Error("External initial import contains duplicate room keys."); + } + roomKeys.add(mapping.normalizedSourceRoomKey); + } + uniqueIds(state.objects, "object"); + const ifcGuids = new Set(); + for (const object of state.objects) { + if ( + object.projectId !== source.projectId || + object.sourceId !== source.id || + object.lastSeenImportBatchId !== batch.id || + object.lastAcceptedImportBatchId !== batch.id + ) { + throw new Error("External initial object identity is invalid."); + } + if (ifcGuids.has(object.ifcGuid)) { + throw new Error("External initial import contains duplicate IFCGUIDs."); + } + ifcGuids.add(object.ifcGuid); + if ( + object.externalRoomMappingId !== null && + !mappingIds.has(object.externalRoomMappingId) + ) { + throw new Error("External initial object room mapping is invalid."); + } + if (object.circuitDeviceRowId !== null) { + throw new Error("External initial import must not assign circuit device rows."); + } + if (object.presenceStatus !== "present") { + throw new Error("External initial import objects must be present."); + } + } +} + +function uniqueIds(entries: ReadonlyArray<{ id: string }>, label: string) { + const ids = new Set(); + for (const entry of entries) { + if (ids.has(entry.id)) { + throw new Error(`External initial import contains duplicate ${label} ids.`); + } + ids.add(entry.id); + } + return ids; +} + +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 e0ee77f..77ad47f 100644 --- a/src/domain/models/project-state-snapshot.model.ts +++ b/src/domain/models/project-state-snapshot.model.ts @@ -24,6 +24,7 @@ import { externalImportKinds, externalModelSourceTypes, externalObjectPresenceStatuses, + type ExternalModelStateSnapshot, } from "../../external-model/domain/external-model-contracts.js"; export const projectStateSnapshotSchemaVersion = 4 as const; @@ -333,6 +334,7 @@ const externalImportBatchSchema = z.object({ fileName: z.string().trim().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/), appliedProjectRevision: z.number().int().nonnegative(), + configurationVersion: z.number().int().positive(), configurationSnapshot: z.custom( (value) => validateExternalCsvConfiguration(value).success ), @@ -414,6 +416,12 @@ const externalModelStateSchema = z.object({ objects: z.array(externalModelObjectSchema), }).strict(); +export function parseExternalModelStateSnapshot( + value: unknown +): ExternalModelStateSnapshot { + return externalModelStateSchema.parse(value); +} + const projectStateSnapshotContents = { circuitLists: z.array(circuitListSchema), circuitSections: z.array(circuitSectionSchema), diff --git a/src/domain/ports/external-initial-import-project-command.store.ts b/src/domain/ports/external-initial-import-project-command.store.ts new file mode 100644 index 0000000..db1917e --- /dev/null +++ b/src/domain/ports/external-initial-import-project-command.store.ts @@ -0,0 +1,19 @@ +import type { ExternalInitialImportProjectCommand } from "../models/external-initial-import-project-command.model.js"; +import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; + +export interface ExecuteExternalInitialImportCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: ExternalInitialImportProjectCommand; +} + +export interface ExternalInitialImportProjectCommandStore { + execute(input: ExecuteExternalInitialImportCommandInput): { + revision: AppendedProjectRevision; + inverse: ExternalInitialImportProjectCommand; + }; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 6900c88..ef6a85a 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -166,6 +166,11 @@ import { externalCsvConfigurationUpdateCommandType, } from "../models/external-csv-configuration-project-command.model.js"; import type { ExternalCsvConfigurationProjectCommandStore } from "../ports/external-csv-configuration-project-command.store.js"; +import { + assertExternalInitialImportProjectCommand, + externalInitialImportCommandType, +} from "../models/external-initial-import-project-command.model.js"; +import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js"; interface DispatchProjectCommandInput { projectId: string; @@ -201,7 +206,8 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore, private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore, private readonly historyStore: ProjectHistoryStore, - private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore + private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore, + private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore ) {} executeUser( @@ -568,6 +574,16 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case externalInitialImportCommandType: { + assertExternalInitialImportProjectCommand(input.command); + if (!this.externalInitialImportStore) { + throw new Error("External initial import store is not available."); + } + return this.externalInitialImportStore.execute({ + ...input, + command: input.command, + }).revision; + } case projectStateRestoreCommandType: { assertProjectStateRestoreCommand(input.command); return this.projectStateRestoreStore.execute({ diff --git a/src/external-model/domain/external-model-contracts.ts b/src/external-model/domain/external-model-contracts.ts index e18d2e5..b06e2fb 100644 --- a/src/external-model/domain/external-model-contracts.ts +++ b/src/external-model/domain/external-model-contracts.ts @@ -30,6 +30,7 @@ export interface ExternalImportBatchSnapshot { fileName: string; sha256: string; appliedProjectRevision: number; + configurationVersion: number; configurationSnapshot: ExternalCsvConfiguration; originalContentBase64: string; document: ExternalCsvDocument; diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index c61838a..129ea1c 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -23,6 +23,7 @@ import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/p 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"; +import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js"; export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); export const circuitDeviceRowProjectCommandStore = @@ -68,6 +69,8 @@ export const projectStateRestoreCommandStore = export const projectHistoryStore = new ProjectHistoryRepository(db); export const externalCsvConfigurationProjectCommandStore = new ExternalCsvConfigurationProjectCommandRepository(db); +export const externalInitialImportProjectCommandStore = + new ExternalInitialImportProjectCommandRepository(db); export const projectCommandService = new ProjectCommandService( circuitProjectCommandStore, circuitDeviceRowProjectCommandStore, @@ -91,5 +94,6 @@ export const projectCommandService = new ProjectCommandService( circuitGroupSubtreeProjectCommandStore, circuitProtectionProjectCommandStore, projectHistoryStore, - externalCsvConfigurationProjectCommandStore + externalCsvConfigurationProjectCommandStore, + externalInitialImportProjectCommandStore ); diff --git a/tests/external-initial-import-project-command.repository.test.ts b/tests/external-initial-import-project-command.repository.test.ts new file mode 100644 index 0000000..3d129f8 --- /dev/null +++ b/tests/external-initial-import-project-command.repository.test.ts @@ -0,0 +1,273 @@ +import crypto from "node:crypto"; +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { createDatabaseContext } from "../src/db/database-context.js"; +import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js"; +import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; +import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js"; +import { projects } from "../src/db/schema/projects.js"; +import { + createEmptyExternalModelState, + createExternalInitialImportProjectCommand, +} from "../src/domain/models/external-initial-import-project-command.model.js"; +import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; +import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js"; +import type { ExternalModelStateSnapshot } from "../src/external-model/domain/external-model-contracts.js"; +import { + createExternalRoomKey, + projectInitialExternalObject, +} from "../src/external-model/domain/external-model-matching.js"; +import { + externalCsvTestConfiguration, + quotedRevitCsv, + utf8Bytes, +} from "./fixtures/revit-csv-fixtures.js"; + +function createTestContext() { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); + const configuration = configuredCsv(); + context.db.insert(externalCsvConfigurations).values({ + id: "config-1", + projectId: "project-1", + configurationVersion: 1, + configuration, + }).run(); + return { context, configuration }; +} + +function configuredCsv() { + const configuration = structuredClone(externalCsvTestConfiguration); + configuration.familyTypeRules.push( + { + exactFamilyAndType: "Steckdose: Doppelsteckdose", + internalDeviceType: "Steckdose", + connectionKind: "socket", + category: "single_phase", + quantityRule: { kind: "mapped-column" }, + displayNameSuggestion: { kind: "selection-marker" }, + }, + { + exactFamilyAndType: "Steckdose: Standard", + internalDeviceType: "Steckdose", + connectionKind: "socket", + category: "single_phase", + quantityRule: { kind: "fixed", quantity: 1 }, + displayNameSuggestion: { kind: "selection-marker" }, + } + ); + return configuration; +} + +function initialState( + configuration: ReturnType +): ExternalModelStateSnapshot { + const bytes = utf8Bytes(quotedRevitCsv, true); + const preview = createExternalCsvPreview({ + fileName: "revit.csv", + bytes, + configuration, + }); + const roomKeys = [...new Set(preview.objects.map((object) => + createExternalRoomKey(object.roomNumber, object.roomName) + ).filter((key): key is string => key !== null))]; + const roomMappings = roomKeys.map((key, index) => { + const object = preview.objects.find( + (candidate) => createExternalRoomKey(candidate.roomNumber, candidate.roomName) === key + )!; + return { + id: `mapping-${index + 1}`, + projectId: "project-1", + sourceId: "source-1", + normalizedSourceRoomKey: key, + sourceFloorName: null, + sourceRoomNumber: object.roomNumber, + sourceRoomName: object.roomName, + roomId: null, + defaultDistributionBoardId: null, + }; + }); + const mappingByKey = new Map( + roomMappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping.id]) + ); + return { + source: { + id: "source-1", + projectId: "project-1", + name: "Revit-Gesamtmodell", + sourceType: "revit_csv", + }, + importBatches: [{ + id: "batch-1", + projectId: "project-1", + sourceId: "source-1", + importKind: "initial", + importedAtIso: "2026-08-02T16:00:00.000Z", + fileName: "revit.csv", + sha256: crypto.createHash("sha256").update(bytes).digest("hex"), + appliedProjectRevision: 1, + configurationVersion: 1, + configurationSnapshot: configuration, + originalContentBase64: Buffer.from(bytes).toString("base64"), + document: parseExternalCsv(bytes, configuration), + }], + roomMappings, + objects: preview.objects.map((candidate, index) => { + const projected = projectInitialExternalObject(candidate, configuration); + const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName); + return { + id: `object-${index + 1}`, + projectId: "project-1", + sourceId: "source-1", + ifcGuid: projected.ifcGuid, + lastSeenImportBatchId: "batch-1", + lastAcceptedImportBatchId: "batch-1", + acceptedSourceValues: projected.sourceValues, + planningValues: projected.planningValues, + overriddenFields: [], + externalRoomMappingId: roomKey === null ? null : mappingByKey.get(roomKey)!, + distributionBoardId: null, + linkedProjectDeviceId: null, + circuitDeviceRowId: null, + presenceStatus: "present", + }; + }), + }; +} + +describe("external initial import project command", () => { + it("commits the complete import and supports persisted undo and redo", () => { + const { context, configuration } = createTestContext(); + try { + const target = initialState(configuration); + const repository = new ExternalInitialImportProjectCommandRepository(context.db); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + description: "Revit-Erstimport übernehmen", + command: createExternalInitialImportProjectCommand( + createEmptyExternalModelState(), + target + ), + }); + assert.equal(inserted.revision.revisionNumber, 1); + assert.equal( + new ExternalModelStateRepository(context.db).getByProject("project-1") + .state.objects.length, + 2 + ); + assert.ok( + new ExternalModelStateRepository(context.db).getByProject("project-1") + .state.objects.every((object) => object.circuitDeviceRowId === null) + ); + + const undone = repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command: inserted.inverse, + }); + assert.equal(undone.revision.revisionNumber, 2); + assert.equal( + new ExternalModelStateRepository(context.db).getByProject("project-1") + .state.source, + null + ); + + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command: undone.inverse, + }); + const redone = new ExternalModelStateRepository(context.db) + .getByProject("project-1").state; + assert.deepEqual(redone.objects.map((object) => object.id), ["object-1", "object-2"]); + assert.equal(redone.importBatches[0].configurationVersion, 1); + } finally { + context.close(); + } + }); + + it("rejects changed state, checksum drift and row assignment", () => { + const { context, configuration } = createTestContext(); + try { + const target = initialState(configuration); + const repository = new ExternalInitialImportProjectCommandRepository(context.db); + const assigned = structuredClone(target); + assigned.objects[0].circuitDeviceRowId = "row-1"; + assert.throws( + () => createExternalInitialImportProjectCommand( + createEmptyExternalModelState(), + assigned + ), + /must not assign circuit device rows/ + ); + + const invalidHash = structuredClone(target); + invalidHash.importBatches[0].sha256 = "b".repeat(64); + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createExternalInitialImportProjectCommand( + createEmptyExternalModelState(), + invalidHash + ), + }), + /checksum is invalid/ + ); + assert.equal( + new ExternalModelStateRepository(context.db).getByProject("project-1") + .state.source, + null + ); + } finally { + context.close(); + } + }); + + it("rolls back all external rows when late history persistence fails", () => { + const { context, configuration } = createTestContext(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_external_import_history + BEFORE INSERT ON project_change_sets + BEGIN + SELECT RAISE(ABORT, 'late history failure'); + END; + `); + const repository = new ExternalInitialImportProjectCommandRepository(context.db); + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createExternalInitialImportProjectCommand( + createEmptyExternalModelState(), + initialState(configuration) + ), + }), + /late history failure/ + ); + assert.equal( + new ExternalModelStateRepository(context.db).getByProject("project-1") + .state.source, + null + ); + assert.equal( + context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, + 0 + ); + } finally { + context.close(); + } + }); +}); diff --git a/tests/external-model-foundation.test.ts b/tests/external-model-foundation.test.ts index 4bc6708..46eed56 100644 --- a/tests/external-model-foundation.test.ts +++ b/tests/external-model-foundation.test.ts @@ -126,6 +126,7 @@ describe("external model state repository", () => { fileName: "revit.csv", sha256: "hash", appliedProjectRevision: 1, + configurationVersion: 1, configurationSnapshot: configuration, originalBytes: Buffer.from(bytes), document, @@ -388,6 +389,7 @@ function insertLinkedExternalFixture(database: ReturnType