From 7c670fece34f26929dcbcb9da043ab6337438e1f Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 25 Jul 2026 22:15:22 +0200 Subject: [PATCH] Add named project snapshots --- AGENTS.md | 7 +- docs/circuit-list-editor-api.md | 12 + docs/circuit-list-editor-known-limitations.md | 3 +- docs/current-architecture.md | 10 + ...history-and-external-model-architecture.md | 16 +- docs/spec/07-implementation-phases-todo.md | 7 +- package.json | 4 +- src/db/migrations/0014_project_snapshots.sql | 16 + src/db/migrations/meta/0014_snapshot.json | 1805 +++++++++++++++++ src/db/migrations/meta/_journal.json | 9 +- .../project-snapshot.repository.ts | 314 +++ src/db/schema/project-snapshots.ts | 36 + .../project-snapshot-name-conflict.error.ts | 11 + .../models/project-state-snapshot.model.ts | 306 +++ src/domain/ports/project-snapshot.store.ts | 26 + .../composition/project-snapshot-store.ts | 5 + .../project-snapshot.controller.ts | 69 + src/server/routes/project.routes.ts | 6 + .../validation/project-snapshot.schemas.ts | 10 + tests/project-snapshot.repository.test.ts | 245 +++ tests/project-state-snapshot.test.ts | 166 ++ 21 files changed, 3076 insertions(+), 7 deletions(-) create mode 100644 src/db/migrations/0014_project_snapshots.sql create mode 100644 src/db/migrations/meta/0014_snapshot.json create mode 100644 src/db/repositories/project-snapshot.repository.ts create mode 100644 src/db/schema/project-snapshots.ts create mode 100644 src/domain/errors/project-snapshot-name-conflict.error.ts create mode 100644 src/domain/models/project-state-snapshot.model.ts create mode 100644 src/domain/ports/project-snapshot.store.ts create mode 100644 src/server/composition/project-snapshot-store.ts create mode 100644 src/server/controllers/project-snapshot.controller.ts create mode 100644 src/shared/validation/project-snapshot.schemas.ts create mode 100644 tests/project-snapshot.repository.test.ts create mode 100644 tests/project-state-snapshot.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7807fca..3955210 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -230,6 +230,11 @@ command and one undo step. Explicit renumbering uses the collision-safe Immutable revision metadata is available through the paginated `GET /api/projects/:projectId/history/revisions` endpoint; it does not expose stored command payloads. +Named logical snapshots can be created and listed through project-scoped API +endpoints. Their schema-versioned payload contains the complete supported +project runtime state and a SHA-256, excludes global/upgrade-only data and does +not change the project revision or undo/redo stacks. Restore is not implemented +yet. Insertions and generated move targets use client-generated stable UUIDs, and undo restores the same ids from complete server snapshots. The tree response supplies the optimistic `currentRevision`; obsolete direct field PATCH, @@ -301,7 +306,7 @@ Users must be able to override sizing suggestions. ## Current Deferred Work -- named logical snapshots and restore +- snapshot restore, snapshot UI and periodic snapshot policy - Revit/CSV/IFCGUID round-trip - full electrical sizing - multi-user/PostgreSQL operation diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 8ce5e47..6cd6e85 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -32,6 +32,18 @@ this value for optimistic concurrency checks. - body: `{ "expectedRevision": 1 }` - executes the eligible inverse or forward command as a new auditable revision +- `GET /projects/:projectId/snapshots` + - lists named logical snapshot metadata without returning payload JSON +- `POST /projects/:projectId/snapshots` + - body: + `{ "expectedRevision": 12, "name": "Vor Ausschreibung", "description": "Optional" }` + - atomically captures the complete project-scoped runtime state at the + expected revision + - returns `201` with source revision, schema version, SHA-256 and creation + metadata + - snapshot creation does not increment the project revision or alter + undo/redo stacks + - duplicate names and stale revisions return `409` The public dispatcher currently supports `circuit.update`, `circuit.insert`, `circuit.delete`, `circuit-device-row.update`, diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 25992a8..09f9d6f 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -7,7 +7,8 @@ - A paginated revision-metadata API exists, but there is no browsable timeline UI yet; the editor currently exposes only the next eligible Undo/Redo actions. -- Named logical project snapshots and restore are not implemented yet. +- Named logical project snapshots can be created and listed through the API, + but snapshot UI, periodic snapshots and restore are not implemented yet. - No Revit/CSV/IFCGUID import and export workflow is implemented yet. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - The global device library supports basic CRUD and copy operations, but has no diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 722b2c2..895afd3 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -100,6 +100,16 @@ Stromkreis-Reorders und die explizite Neunummerierung im Grid verwenden die persistenten Kommandos. Die Toolbar leitet ihre Undo-/Redo-Verfügbarkeit direkt aus den serverseitigen Stack-Tiefen ab, sodass ein Reload die Bedienbarkeit nicht unterbricht. + +Benannte logische Projektstände werden in `project_snapshots` getrennt von +Datenbank-Backups gespeichert. `POST /api/projects/:projectId/snapshots` +erzeugt bei passender erwarteter Revision transaktional einen vollständigen, +schema-versionierten Projektzustand mit SHA-256-Prüfwert. Enthalten sind +Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und +Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte, +Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots. +Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel; Restore ist +noch nicht implementiert. `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 8056495..af71f0d 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -75,6 +75,20 @@ Project history must use logical project data, not copies of the SQLite database Large snapshots may later be compressed or stored in object storage, with metadata and checksums retained in the database. +Implemented named-snapshot foundation: + +- `project_snapshots` stores project ownership, source revision, schema + version, name, optional description, logical payload, SHA-256 and creation + metadata +- snapshot creation requires the expected current revision and reads plus + stores the complete project state in one SQLite transaction +- the versioned payload contains project settings, distribution boards, + circuit lists, sections, circuits with device rows, project devices, floors + and rooms while excluding global and upgrade-only data +- snapshot creation/listing does not create a project revision or affect + undo/redo eligibility +- public Create/List endpoints expose metadata but not the logical payload + ## Revit / CSV / IFCGUID Round-Trip Direction External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits. @@ -229,7 +243,7 @@ Completed foundation: Remaining constraints before completing project version history: -- add a browsable timeline UI plus named logical snapshots and restore +- add a browsable timeline/snapshot UI, periodic snapshot policy and restore - extend project-scoped commands only when further project mutations are deliberately added to history; the generic versioned envelope is complete - do not model IFCGUID as an overloaded circuit equipment identifier diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 91f5303..70ef930 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -366,7 +366,9 @@ Tasks: update command types - [x] expose a cursor-paginated, read-only revision metadata timeline without command payloads -- add named and periodic logical project snapshots +- [x] add explicitly named logical project snapshots with schema-versioned + payloads and integrity checksums +- add periodic logical project snapshots - restore a historical snapshot as a new revision - keep database backups separate from logical history @@ -391,6 +393,9 @@ Implemented foundation: change-set ids - a separate read-only timeline endpoint exposes immutable revision metadata in descending, cursor-paginated pages without forward/inverse payloads +- named snapshot Create/List endpoints persist complete project-scoped runtime + state with the source revision and SHA-256 without changing revision or + undo/redo stacks - a central application dispatcher reconstructs persisted commands and exposes optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow field updates diff --git a/package.json b/package.json index ab8fa6c..b392533 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/migrations/0014_project_snapshots.sql b/src/db/migrations/0014_project_snapshots.sql new file mode 100644 index 0000000..a088cb4 --- /dev/null +++ b/src/db/migrations/0014_project_snapshots.sql @@ -0,0 +1,16 @@ +CREATE TABLE `project_snapshots` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `source_revision` integer NOT NULL, + `schema_version` integer NOT NULL, + `name` text NOT NULL, + `description` text, + `payload_json` text NOT NULL, + `payload_sha256` text NOT NULL, + `created_at_iso` text NOT NULL, + `created_by_actor_id` text, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `project_snapshots_project_created_idx` ON `project_snapshots` (`project_id`,`created_at_iso`);--> statement-breakpoint +CREATE UNIQUE INDEX `project_snapshots_project_name_unique` ON `project_snapshots` (`project_id`,`name`); \ No newline at end of file diff --git a/src/db/migrations/meta/0014_snapshot.json b/src/db/migrations/meta/0014_snapshot.json new file mode 100644 index 0000000..783e5ad --- /dev/null +++ b/src/db/migrations/meta/0014_snapshot.json @@ -0,0 +1,1805 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bd9bc99c-0cc0-44e3-bff0-819b1cbbf8ac", + "prevId": "b4029fef-ed7b-47ed-a185-d151ad3f46da", + "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 + }, + "legacy_consumer_id": { + "name": "legacy_consumer_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_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_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 + } + }, + "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 + } + }, + "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 + }, + "protection_type": { + "name": "protection_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protection_rated_current": { + "name": "protection_rated_current", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protection_characteristic": { + "name": "protection_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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": {} + }, + "consumers": { + "name": "consumers", + "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": false, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_device_id": { + "name": "project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_linked_to_device": { + "name": "is_linked_to_device", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_number": { + "name": "circuit_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trade_or_cost_group": { + "name": "trade_or_cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protection_type": { + "name": "protection_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protection_rated_current": { + "name": "protection_rated_current", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protection_characteristic": { + "name": "protection_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "comment": { + "name": "comment", + "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": { + "consumers_project_id_projects_id_fk": { + "name": "consumers_project_id_projects_id_fk", + "tableFrom": "consumers", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "consumers_distribution_board_id_distribution_boards_id_fk": { + "name": "consumers_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "consumers", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "consumers_circuit_list_id_circuit_lists_id_fk": { + "name": "consumers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "consumers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "consumers_project_device_id_project_devices_id_fk": { + "name": "consumers_project_device_id_project_devices_id_fk", + "tableFrom": "consumers", + "tableTo": "project_devices", + "columnsFrom": [ + "project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "consumers_room_id_rooms_id_fk": { + "name": "consumers_room_id_rooms_id_fk", + "tableFrom": "consumers", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + } + }, + "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" + } + }, + "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": {} + }, + "legacy_consumer_circuit_migrations": { + "name": "legacy_consumer_circuit_migrations", + "columns": { + "consumer_id": { + "name": "consumer_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "legacy_consumer_circuit_migrations_circuit_id_circuits_id_fk": { + "name": "legacy_consumer_circuit_migrations_circuit_id_circuits_id_fk", + "tableFrom": "legacy_consumer_circuit_migrations", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "legacy_consumer_circuit_migrations_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "legacy_consumer_circuit_migrations_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "legacy_consumer_circuit_migrations", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "legacy_consumer_circuit_migrations_circuit_list_id_circuit_lists_id_fk": { + "name": "legacy_consumer_circuit_migrations_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "legacy_consumer_circuit_migrations", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_consumer_migration_reports": { + "name": "legacy_consumer_migration_reports", + "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 + }, + "legacy_consumer_count": { + "name": "legacy_consumer_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_circuit_count": { + "name": "created_circuit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_device_row_count": { + "name": "created_device_row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duplicate_grouped_count": { + "name": "duplicate_grouped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generated_identifier_count": { + "name": "generated_identifier_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unassigned_row_count": { + "name": "unassigned_row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "warnings_json": { + "name": "warnings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generated_identifiers_json": { + "name": "generated_identifiers_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duplicate_groups_json": { + "name": "duplicate_groups_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "legacy_consumer_migration_reports_list_unique": { + "name": "legacy_consumer_migration_reports_list_unique", + "columns": [ + "circuit_list_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "legacy_consumer_migration_reports_circuit_list_id_circuit_lists_id_fk": { + "name": "legacy_consumer_migration_reports_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "legacy_consumer_migration_reports", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "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_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 + }, + "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 + }, + "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 63e5072..eb554ad 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1784835703230, "tag": "0013_project_history_stacks", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1785009799979, + "tag": "0014_project_snapshots", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/db/repositories/project-snapshot.repository.ts b/src/db/repositories/project-snapshot.repository.ts new file mode 100644 index 0000000..ae4a5c2 --- /dev/null +++ b/src/db/repositories/project-snapshot.repository.ts @@ -0,0 +1,314 @@ +import crypto from "node:crypto"; +import { and, asc, desc, eq } from "drizzle-orm"; +import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js"; +import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js"; +import { + parseProjectStateSnapshot, + projectStateSnapshotSchemaVersion, + serializeProjectStateSnapshot, +} from "../../domain/models/project-state-snapshot.model.js"; +import type { + CreateNamedProjectSnapshotInput, + ProjectSnapshotMetadata, + ProjectSnapshotStore, +} from "../../domain/ports/project-snapshot.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitSections } from "../schema/circuit-sections.js"; +import { circuits } from "../schema/circuits.js"; +import { distributionBoards } from "../schema/distribution-boards.js"; +import { floors } from "../schema/floors.js"; +import { projectDevices } from "../schema/project-devices.js"; +import { projectSnapshots } from "../schema/project-snapshots.js"; +import { projects } from "../schema/projects.js"; +import { rooms } from "../schema/rooms.js"; + +export class ProjectSnapshotRepository implements ProjectSnapshotStore { + constructor(private readonly database: AppDatabase) {} + + createNamed( + input: CreateNamedProjectSnapshotInput + ): ProjectSnapshotMetadata | null { + validateCreateInput(input); + const snapshotName = input.name.trim(); + const snapshotDescription = + input.description?.trim() || null; + return this.database.transaction((tx) => { + const project = tx + .select({ + id: projects.id, + name: projects.name, + singlePhaseVoltageV: projects.singlePhaseVoltageV, + threePhaseVoltageV: projects.threePhaseVoltageV, + currentRevision: projects.currentRevision, + }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + if (!project) { + return null; + } + if (project.currentRevision !== input.expectedRevision) { + throw new ProjectRevisionConflictError( + input.projectId, + input.expectedRevision, + project.currentRevision + ); + } + const existing = tx + .select({ id: projectSnapshots.id }) + .from(projectSnapshots) + .where( + and( + eq(projectSnapshots.projectId, input.projectId), + eq(projectSnapshots.name, snapshotName) + ) + ) + .get(); + if (existing) { + throw new ProjectSnapshotNameConflictError( + input.projectId, + snapshotName + ); + } + + const boardRows = tx + .select() + .from(distributionBoards) + .where(eq(distributionBoards.projectId, input.projectId)) + .orderBy(asc(distributionBoards.name), asc(distributionBoards.id)) + .all(); + const listRows = tx + .select() + .from(circuitLists) + .where(eq(circuitLists.projectId, input.projectId)) + .orderBy(asc(circuitLists.name), asc(circuitLists.id)) + .all(); + const sectionRows = tx + .select({ + id: circuitSections.id, + circuitListId: circuitSections.circuitListId, + key: circuitSections.key, + displayName: circuitSections.displayName, + prefix: circuitSections.prefix, + sortOrder: circuitSections.sortOrder, + }) + .from(circuitSections) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuitSections.circuitListId) + ) + .where(eq(circuitLists.projectId, input.projectId)) + .orderBy( + asc(circuitSections.circuitListId), + asc(circuitSections.sortOrder), + asc(circuitSections.id) + ) + .all(); + const circuitRows = tx + .select({ + id: circuits.id, + circuitListId: circuits.circuitListId, + sectionId: circuits.sectionId, + equipmentIdentifier: circuits.equipmentIdentifier, + displayName: circuits.displayName, + sortOrder: circuits.sortOrder, + protectionType: circuits.protectionType, + protectionRatedCurrent: circuits.protectionRatedCurrent, + protectionCharacteristic: circuits.protectionCharacteristic, + cableType: circuits.cableType, + cableCrossSection: circuits.cableCrossSection, + cableLength: circuits.cableLength, + rcdAssignment: circuits.rcdAssignment, + terminalDesignation: circuits.terminalDesignation, + voltage: circuits.voltage, + controlRequirement: circuits.controlRequirement, + status: circuits.status, + isReserve: circuits.isReserve, + remark: circuits.remark, + }) + .from(circuits) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuits.circuitListId) + ) + .where(eq(circuitLists.projectId, input.projectId)) + .orderBy( + asc(circuits.circuitListId), + asc(circuits.sortOrder), + asc(circuits.id) + ) + .all(); + const deviceRowRows = tx + .select({ + id: circuitDeviceRows.id, + circuitId: circuitDeviceRows.circuitId, + linkedProjectDeviceId: + circuitDeviceRows.linkedProjectDeviceId, + legacyConsumerId: circuitDeviceRows.legacyConsumerId, + sortOrder: circuitDeviceRows.sortOrder, + name: circuitDeviceRows.name, + displayName: circuitDeviceRows.displayName, + phaseType: circuitDeviceRows.phaseType, + connectionKind: circuitDeviceRows.connectionKind, + costGroup: circuitDeviceRows.costGroup, + category: circuitDeviceRows.category, + level: circuitDeviceRows.level, + roomId: circuitDeviceRows.roomId, + roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot, + roomNameSnapshot: circuitDeviceRows.roomNameSnapshot, + quantity: circuitDeviceRows.quantity, + powerPerUnit: circuitDeviceRows.powerPerUnit, + simultaneityFactor: + circuitDeviceRows.simultaneityFactor, + cosPhi: circuitDeviceRows.cosPhi, + remark: circuitDeviceRows.remark, + overriddenFields: circuitDeviceRows.overriddenFields, + }) + .from(circuitDeviceRows) + .innerJoin( + circuits, + eq(circuits.id, circuitDeviceRows.circuitId) + ) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuits.circuitListId) + ) + .where(eq(circuitLists.projectId, input.projectId)) + .orderBy( + asc(circuitDeviceRows.circuitId), + asc(circuitDeviceRows.sortOrder), + asc(circuitDeviceRows.id) + ) + .all(); + const projectDeviceRows = tx + .select() + .from(projectDevices) + .where(eq(projectDevices.projectId, input.projectId)) + .orderBy( + asc(projectDevices.displayName), + asc(projectDevices.id) + ) + .all(); + const floorRows = tx + .select() + .from(floors) + .where(eq(floors.projectId, input.projectId)) + .orderBy(asc(floors.sortOrder), asc(floors.id)) + .all(); + const roomRows = tx + .select() + .from(rooms) + .where(eq(rooms.projectId, input.projectId)) + .orderBy(asc(rooms.roomNumber), asc(rooms.id)) + .all(); + const deviceRowsByCircuit = new Map(); + for (const row of deviceRowRows) { + const entries = deviceRowsByCircuit.get(row.circuitId) ?? []; + entries.push(row); + deviceRowsByCircuit.set(row.circuitId, entries); + } + + const snapshot = parseProjectStateSnapshot({ + schemaVersion: projectStateSnapshotSchemaVersion, + project: { + id: project.id, + name: project.name, + singlePhaseVoltageV: project.singlePhaseVoltageV, + threePhaseVoltageV: project.threePhaseVoltageV, + }, + distributionBoards: boardRows, + circuitLists: listRows, + circuitSections: sectionRows, + circuits: circuitRows.map((circuit) => ({ + ...circuit, + isReserve: Boolean(circuit.isReserve), + deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [], + })), + projectDevices: projectDeviceRows, + floors: floorRows, + rooms: roomRows, + }); + const payloadJson = serializeProjectStateSnapshot(snapshot); + const metadata: ProjectSnapshotMetadata = { + id: crypto.randomUUID(), + projectId: input.projectId, + sourceRevision: project.currentRevision, + schemaVersion: projectStateSnapshotSchemaVersion, + name: snapshotName, + description: snapshotDescription, + payloadSha256: crypto + .createHash("sha256") + .update(payloadJson) + .digest("hex"), + createdAtIso: new Date().toISOString(), + createdByActorId: input.actorId ?? null, + }; + tx.insert(projectSnapshots) + .values({ + ...metadata, + payloadJson, + }) + .run(); + return metadata; + }); + } + + listByProject(projectId: string): ProjectSnapshotMetadata[] | null { + const project = this.database + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (!project) { + return null; + } + return this.database + .select({ + id: projectSnapshots.id, + projectId: projectSnapshots.projectId, + sourceRevision: projectSnapshots.sourceRevision, + schemaVersion: projectSnapshots.schemaVersion, + name: projectSnapshots.name, + description: projectSnapshots.description, + payloadSha256: projectSnapshots.payloadSha256, + createdAtIso: projectSnapshots.createdAtIso, + createdByActorId: projectSnapshots.createdByActorId, + }) + .from(projectSnapshots) + .where(eq(projectSnapshots.projectId, projectId)) + .orderBy( + desc(projectSnapshots.createdAtIso), + desc(projectSnapshots.id) + ) + .all(); + } +} + +function validateCreateInput(input: CreateNamedProjectSnapshotInput) { + if ( + !Number.isSafeInteger(input.expectedRevision) || + input.expectedRevision < 0 + ) { + throw new Error( + "Expected project revision must be a non-negative integer." + ); + } + if (!input.name.trim()) { + throw new Error("Project snapshot name is required."); + } + if (input.name.trim().length > 100) { + throw new Error( + "Project snapshot name must not exceed 100 characters." + ); + } + if ( + input.description !== undefined && + input.description.trim().length > 500 + ) { + throw new Error( + "Project snapshot description must not exceed 500 characters." + ); + } +} diff --git a/src/db/schema/project-snapshots.ts b/src/db/schema/project-snapshots.ts new file mode 100644 index 0000000..f01de9a --- /dev/null +++ b/src/db/schema/project-snapshots.ts @@ -0,0 +1,36 @@ +import { + index, + integer, + sqliteTable, + text, + unique, +} from "drizzle-orm/sqlite-core"; +import { projects } from "./projects.js"; + +export const projectSnapshots = sqliteTable( + "project_snapshots", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + sourceRevision: integer("source_revision").notNull(), + schemaVersion: integer("schema_version").notNull(), + name: text("name").notNull(), + description: text("description"), + payloadJson: text("payload_json").notNull(), + payloadSha256: text("payload_sha256").notNull(), + createdAtIso: text("created_at_iso").notNull(), + createdByActorId: text("created_by_actor_id"), + }, + (table) => [ + unique("project_snapshots_project_name_unique").on( + table.projectId, + table.name + ), + index("project_snapshots_project_created_idx").on( + table.projectId, + table.createdAtIso + ), + ] +); diff --git a/src/domain/errors/project-snapshot-name-conflict.error.ts b/src/domain/errors/project-snapshot-name-conflict.error.ts new file mode 100644 index 0000000..30fb37d --- /dev/null +++ b/src/domain/errors/project-snapshot-name-conflict.error.ts @@ -0,0 +1,11 @@ +export class ProjectSnapshotNameConflictError extends Error { + constructor( + readonly projectId: string, + readonly snapshotName: string + ) { + super( + `Project ${projectId} already has a snapshot named "${snapshotName}".` + ); + this.name = "ProjectSnapshotNameConflictError"; + } +} diff --git a/src/domain/models/project-state-snapshot.model.ts b/src/domain/models/project-state-snapshot.model.ts new file mode 100644 index 0000000..7b44ea2 --- /dev/null +++ b/src/domain/models/project-state-snapshot.model.ts @@ -0,0 +1,306 @@ +import { z } from "zod"; + +export const projectStateSnapshotSchemaVersion = 1 as const; + +const idSchema = z.string().trim().min(1); +const nullableStringSchema = z.string().nullable(); +const finiteNumberSchema = z.number().finite(); + +const projectSchema = z + .object({ + id: idSchema, + name: z.string().trim().min(1), + singlePhaseVoltageV: finiteNumberSchema.positive(), + threePhaseVoltageV: finiteNumberSchema.positive(), + }) + .strict(); + +const distributionBoardSchema = z + .object({ + id: idSchema, + projectId: idSchema, + name: z.string().trim().min(1), + }) + .strict(); + +const circuitListSchema = z + .object({ + id: idSchema, + projectId: idSchema, + distributionBoardId: idSchema, + name: z.string().trim().min(1), + }) + .strict(); + +const circuitSectionSchema = z + .object({ + id: idSchema, + circuitListId: idSchema, + key: z.string().trim().min(1), + displayName: z.string().trim().min(1), + prefix: z.string().trim().min(1), + sortOrder: finiteNumberSchema, + }) + .strict(); + +const circuitDeviceRowSchema = z + .object({ + id: idSchema, + circuitId: idSchema, + linkedProjectDeviceId: idSchema.nullable(), + legacyConsumerId: z.string().nullable(), + sortOrder: finiteNumberSchema, + name: z.string().trim().min(1), + displayName: z.string().trim().min(1), + phaseType: nullableStringSchema, + connectionKind: nullableStringSchema, + costGroup: nullableStringSchema, + category: nullableStringSchema, + level: nullableStringSchema, + roomId: idSchema.nullable(), + roomNumberSnapshot: nullableStringSchema, + roomNameSnapshot: nullableStringSchema, + quantity: finiteNumberSchema.nonnegative(), + powerPerUnit: finiteNumberSchema.nonnegative(), + simultaneityFactor: finiteNumberSchema.nonnegative(), + cosPhi: finiteNumberSchema.positive().nullable(), + remark: nullableStringSchema, + overriddenFields: nullableStringSchema, + }) + .strict(); + +const circuitSchema = z + .object({ + id: idSchema, + circuitListId: idSchema, + sectionId: idSchema, + equipmentIdentifier: z.string().trim().min(1), + displayName: nullableStringSchema, + sortOrder: finiteNumberSchema, + protectionType: nullableStringSchema, + protectionRatedCurrent: finiteNumberSchema.nonnegative().nullable(), + protectionCharacteristic: nullableStringSchema, + cableType: nullableStringSchema, + cableCrossSection: nullableStringSchema, + cableLength: finiteNumberSchema.nonnegative().nullable(), + rcdAssignment: nullableStringSchema, + terminalDesignation: nullableStringSchema, + voltage: finiteNumberSchema.positive().nullable(), + controlRequirement: nullableStringSchema, + status: nullableStringSchema, + isReserve: z.boolean(), + remark: nullableStringSchema, + deviceRows: z.array(circuitDeviceRowSchema), + }) + .strict(); + +const projectDeviceSchema = z + .object({ + id: idSchema, + projectId: idSchema, + name: z.string().trim().min(1), + displayName: z.string().trim().min(1), + phaseType: z.enum(["single_phase", "three_phase"]), + connectionKind: nullableStringSchema, + costGroup: nullableStringSchema, + category: nullableStringSchema, + quantity: finiteNumberSchema.nonnegative(), + powerPerUnit: finiteNumberSchema.nonnegative(), + simultaneityFactor: finiteNumberSchema.min(0).max(1), + cosPhi: finiteNumberSchema.min(0).max(1).nullable(), + remark: nullableStringSchema, + voltageV: finiteNumberSchema.positive().nullable(), + }) + .strict(); + +const floorSchema = z + .object({ + id: idSchema, + projectId: idSchema, + name: z.string().trim().min(1), + sortOrder: finiteNumberSchema, + }) + .strict(); + +const roomSchema = z + .object({ + id: idSchema, + projectId: idSchema, + floorId: idSchema.nullable(), + roomNumber: z.string().trim().min(1), + roomName: z.string().trim().min(1), + }) + .strict(); + +export const projectStateSnapshotSchema = z + .object({ + schemaVersion: z.literal(projectStateSnapshotSchemaVersion), + project: projectSchema, + distributionBoards: z.array(distributionBoardSchema), + circuitLists: z.array(circuitListSchema), + circuitSections: z.array(circuitSectionSchema), + circuits: z.array(circuitSchema), + projectDevices: z.array(projectDeviceSchema), + floors: z.array(floorSchema), + rooms: z.array(roomSchema), + }) + .strict(); + +export type ProjectStateSnapshot = z.infer< + typeof projectStateSnapshotSchema +>; + +export function parseProjectStateSnapshot( + value: unknown +): ProjectStateSnapshot { + const snapshot = projectStateSnapshotSchema.parse(value); + assertProjectStateSnapshotRelations(snapshot); + return snapshot; +} + +export function serializeProjectStateSnapshot( + snapshot: ProjectStateSnapshot +): string { + return JSON.stringify(parseProjectStateSnapshot(snapshot)); +} + +export function deserializeProjectStateSnapshot( + serialized: string +): ProjectStateSnapshot { + return parseProjectStateSnapshot(JSON.parse(serialized) as unknown); +} + +function assertProjectStateSnapshotRelations( + snapshot: ProjectStateSnapshot +) { + const projectId = snapshot.project.id; + const boardIds = uniqueIds( + snapshot.distributionBoards, + "distribution board" + ); + const circuitListIds = uniqueIds( + snapshot.circuitLists, + "circuit list" + ); + const sectionIds = uniqueIds( + snapshot.circuitSections, + "circuit section" + ); + const projectDeviceIds = uniqueIds( + snapshot.projectDevices, + "project device" + ); + const floorIds = uniqueIds(snapshot.floors, "floor"); + const roomIds = uniqueIds(snapshot.rooms, "room"); + uniqueIds(snapshot.circuits, "circuit"); + const sectionById = new Map( + snapshot.circuitSections.map((entry) => [entry.id, entry]) + ); + + for (const board of snapshot.distributionBoards) { + assertProjectOwnership(board.projectId, projectId, "distribution board"); + } + for (const list of snapshot.circuitLists) { + assertProjectOwnership(list.projectId, projectId, "circuit list"); + assertReference( + boardIds, + list.distributionBoardId, + "circuit list distribution board" + ); + } + for (const section of snapshot.circuitSections) { + assertReference( + circuitListIds, + section.circuitListId, + "circuit section list" + ); + } + for (const device of snapshot.projectDevices) { + assertProjectOwnership(device.projectId, projectId, "project device"); + } + for (const floor of snapshot.floors) { + assertProjectOwnership(floor.projectId, projectId, "floor"); + } + for (const room of snapshot.rooms) { + assertProjectOwnership(room.projectId, projectId, "room"); + if (room.floorId !== null) { + assertReference(floorIds, room.floorId, "room floor"); + } + } + + const deviceRowIds = new Set(); + for (const circuit of snapshot.circuits) { + assertReference( + circuitListIds, + circuit.circuitListId, + "circuit list" + ); + assertReference(sectionIds, circuit.sectionId, "circuit section"); + const section = sectionById.get(circuit.sectionId)!; + if (section.circuitListId !== circuit.circuitListId) { + throw new Error( + "Snapshot circuit section belongs to a different circuit list." + ); + } + if (circuit.isReserve !== (circuit.deviceRows.length === 0)) { + throw new Error( + "Snapshot circuit reserve state must match its device rows." + ); + } + for (const row of circuit.deviceRows) { + if (row.circuitId !== circuit.id) { + throw new Error( + "Snapshot device row belongs to a different circuit." + ); + } + if (deviceRowIds.has(row.id)) { + throw new Error("Snapshot contains duplicate device row ids."); + } + deviceRowIds.add(row.id); + if (row.linkedProjectDeviceId !== null) { + assertReference( + projectDeviceIds, + row.linkedProjectDeviceId, + "device row project device" + ); + } + if (row.roomId !== null) { + assertReference(roomIds, row.roomId, "device row room"); + } + } + } +} + +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(`Snapshot contains duplicate ${label} ids.`); + } + ids.add(entry.id); + } + return ids; +} + +function assertProjectOwnership( + actualProjectId: string, + expectedProjectId: string, + label: string +) { + if (actualProjectId !== expectedProjectId) { + throw new Error(`Snapshot ${label} belongs to a different project.`); + } +} + +function assertReference( + ids: ReadonlySet, + id: string, + label: string +) { + if (!ids.has(id)) { + throw new Error(`Snapshot ${label} reference is invalid.`); + } +} diff --git a/src/domain/ports/project-snapshot.store.ts b/src/domain/ports/project-snapshot.store.ts new file mode 100644 index 0000000..fd02fd4 --- /dev/null +++ b/src/domain/ports/project-snapshot.store.ts @@ -0,0 +1,26 @@ +export interface ProjectSnapshotMetadata { + id: string; + projectId: string; + sourceRevision: number; + schemaVersion: number; + name: string; + description: string | null; + payloadSha256: string; + createdAtIso: string; + createdByActorId: string | null; +} + +export interface CreateNamedProjectSnapshotInput { + projectId: string; + expectedRevision: number; + name: string; + description?: string; + actorId?: string; +} + +export interface ProjectSnapshotStore { + createNamed( + input: CreateNamedProjectSnapshotInput + ): ProjectSnapshotMetadata | null; + listByProject(projectId: string): ProjectSnapshotMetadata[] | null; +} diff --git a/src/server/composition/project-snapshot-store.ts b/src/server/composition/project-snapshot-store.ts new file mode 100644 index 0000000..f83805d --- /dev/null +++ b/src/server/composition/project-snapshot-store.ts @@ -0,0 +1,5 @@ +import { db } from "../../db/client.js"; +import { ProjectSnapshotRepository } from "../../db/repositories/project-snapshot.repository.js"; + +export const projectSnapshotStore = + new ProjectSnapshotRepository(db); diff --git a/src/server/controllers/project-snapshot.controller.ts b/src/server/controllers/project-snapshot.controller.ts new file mode 100644 index 0000000..4a72496 --- /dev/null +++ b/src/server/controllers/project-snapshot.controller.ts @@ -0,0 +1,69 @@ +import type { NextFunction, Request, Response } from "express"; +import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js"; +import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js"; +import { createNamedProjectSnapshotSchema } from "../../shared/validation/project-snapshot.schemas.js"; +import { projectSnapshotStore } from "../composition/project-snapshot-store.js"; + +export function listProjectSnapshots(req: Request, res: Response) { + const projectId = getProjectId(req, res); + if (!projectId) { + return; + } + const snapshots = projectSnapshotStore.listByProject(projectId); + if (!snapshots) { + return res.status(404).json({ error: "Project not found" }); + } + return res.json(snapshots); +} + +export function createNamedProjectSnapshot( + req: Request, + res: Response, + next: NextFunction +) { + const projectId = getProjectId(req, res); + if (!projectId) { + return; + } + const parsed = createNamedProjectSnapshotSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + try { + const snapshot = projectSnapshotStore.createNamed({ + projectId, + ...parsed.data, + }); + if (!snapshot) { + return res.status(404).json({ error: "Project not found" }); + } + return res.status(201).json(snapshot); + } catch (error) { + if (error instanceof ProjectRevisionConflictError) { + return res.status(409).json({ + error: error.message, + code: "PROJECT_REVISION_CONFLICT", + expectedRevision: error.expectedRevision, + currentRevision: error.actualRevision, + }); + } + if (error instanceof ProjectSnapshotNameConflictError) { + return res.status(409).json({ + error: error.message, + code: "PROJECT_SNAPSHOT_NAME_CONFLICT", + snapshotName: error.snapshotName, + }); + } + return next(error); + } +} + +function getProjectId(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string" || !projectId.trim()) { + res.status(400).json({ error: "Invalid projectId" }); + return null; + } + return projectId; +} diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 201d6b4..1341c8b 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -22,6 +22,10 @@ import { redoProjectCommand, undoProjectCommand, } from "../controllers/project-command.controller.js"; +import { + createNamedProjectSnapshot, + listProjectSnapshots, +} from "../controllers/project-snapshot.controller.js"; export const projectRouter = Router(); @@ -33,6 +37,8 @@ projectRouter.get("/:projectId/history/revisions", listProjectRevisions); projectRouter.post("/:projectId/commands", executeProjectCommand); projectRouter.post("/:projectId/history/undo", undoProjectCommand); projectRouter.post("/:projectId/history/redo", redoProjectCommand); +projectRouter.get("/:projectId/snapshots", listProjectSnapshots); +projectRouter.post("/:projectId/snapshots", createNamedProjectSnapshot); projectRouter.put("/:projectId", updateProjectSettings); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); diff --git a/src/shared/validation/project-snapshot.schemas.ts b/src/shared/validation/project-snapshot.schemas.ts new file mode 100644 index 0000000..d7edbb5 --- /dev/null +++ b/src/shared/validation/project-snapshot.schemas.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; +import { expectedProjectRevisionSchema } from "./project-command.schemas.js"; + +export const createNamedProjectSnapshotSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + name: z.string().trim().min(1).max(100), + description: z.string().trim().max(500).optional(), + }) + .strict(); diff --git a/tests/project-snapshot.repository.test.ts b/tests/project-snapshot.repository.test.ts new file mode 100644 index 0000000..70afab3 --- /dev/null +++ b/tests/project-snapshot.repository.test.ts @@ -0,0 +1,245 @@ +import crypto from "node:crypto"; +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; +import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; +import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitLists } from "../src/db/schema/circuit-lists.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { floors } from "../src/db/schema/floors.js"; +import { projectDevices } from "../src/db/schema/project-devices.js"; +import { projectSnapshots } from "../src/db/schema/project-snapshots.js"; +import { projects } from "../src/db/schema/projects.js"; +import { rooms } from "../src/db/schema/rooms.js"; +import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js"; +import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; +import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.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: "Snapshot-Projekt", + singlePhaseVoltageV: 230, + threePhaseVoltageV: 400, + }) + .run(); + const board = new DistributionBoardRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-1", "UV-01"); + const list = context.db + .select() + .from(circuitLists) + .where(eq(circuitLists.distributionBoardId, board.id)) + .get(); + assert.ok(list); + const section = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, list.id)) + .get(); + assert.ok(section); + context.db + .insert(floors) + .values({ + id: "floor-1", + projectId: "project-1", + name: "EG", + sortOrder: 10, + }) + .run(); + context.db + .insert(rooms) + .values({ + id: "room-1", + projectId: "project-1", + floorId: "floor-1", + roomNumber: "001", + roomName: "Technik", + }) + .run(); + context.db + .insert(projectDevices) + .values({ + id: "device-1", + projectId: "project-1", + name: "Pumpe", + displayName: "Pumpe", + phaseType: "three_phase", + quantity: 1, + powerPerUnit: 2.5, + simultaneityFactor: 0.8, + cosPhi: 0.9, + voltageV: 400, + }) + .run(); + context.db + .insert(circuits) + .values({ + id: "circuit-1", + circuitListId: list.id, + sectionId: section.id, + equipmentIdentifier: "-1F1", + displayName: "Technik", + sortOrder: 10, + voltage: 230, + isReserve: 0, + }) + .run(); + context.db + .insert(circuitDeviceRows) + .values({ + id: "row-1", + circuitId: "circuit-1", + linkedProjectDeviceId: "device-1", + roomId: "room-1", + sortOrder: 10, + name: "Pumpe", + displayName: "Pumpe lokal", + phaseType: "three_phase", + quantity: 1, + powerPerUnit: 2.5, + simultaneityFactor: 0.8, + cosPhi: 0.9, + }) + .run(); + return context; +} + +describe("project snapshot repository", () => { + it("captures, hashes and lists a complete named logical snapshot", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectSnapshotRepository(context.db); + const created = repository.createNamed({ + projectId: "project-1", + expectedRevision: 0, + name: " Vor Ausschreibung ", + description: " Sicherer Stand ", + actorId: "planner-1", + }); + assert.ok(created); + assert.equal(created.sourceRevision, 0); + assert.equal(created.schemaVersion, 1); + assert.equal(created.name, "Vor Ausschreibung"); + assert.equal(created.description, "Sicherer Stand"); + assert.equal(created.createdByActorId, "planner-1"); + assert.match(created.payloadSha256, /^[a-f0-9]{64}$/); + assert.equal( + context.db + .select({ currentRevision: projects.currentRevision }) + .from(projects) + .where(eq(projects.id, "project-1")) + .get()?.currentRevision, + 0 + ); + + const persisted = context.db + .select() + .from(projectSnapshots) + .where(eq(projectSnapshots.id, created.id)) + .get(); + assert.ok(persisted); + assert.equal( + crypto + .createHash("sha256") + .update(persisted.payloadJson) + .digest("hex"), + persisted.payloadSha256 + ); + const state = deserializeProjectStateSnapshot( + persisted.payloadJson + ); + assert.equal(state.project.id, "project-1"); + assert.equal(state.distributionBoards.length, 1); + assert.equal(state.circuitLists.length, 1); + assert.equal( + state.circuitSections.length, + context.db.select().from(circuitSections).all().length + ); + assert.equal(state.circuits.length, 1); + assert.equal(state.circuits[0].deviceRows.length, 1); + assert.equal( + state.circuits[0].deviceRows[0].linkedProjectDeviceId, + "device-1" + ); + assert.equal(state.projectDevices.length, 1); + assert.equal(state.floors.length, 1); + assert.equal(state.rooms.length, 1); + assert.deepEqual(repository.listByProject("project-1"), [ + created, + ]); + } finally { + context.close(); + } + }); + + it("rejects stale revisions and duplicate names without partial rows", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectSnapshotRepository(context.db); + assert.throws( + () => + repository.createNamed({ + projectId: "project-1", + expectedRevision: 1, + name: "Stale", + }), + ProjectRevisionConflictError + ); + const created = repository.createNamed({ + projectId: "project-1", + expectedRevision: 0, + name: "Freigabe", + }); + assert.ok(created); + assert.throws( + () => + repository.createNamed({ + projectId: "project-1", + expectedRevision: 0, + name: " Freigabe ", + }), + ProjectSnapshotNameConflictError + ); + assert.equal( + context.db.select().from(projectSnapshots).all().length, + 1 + ); + } finally { + context.close(); + } + }); + + it("distinguishes unknown projects from empty snapshot lists", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectSnapshotRepository(context.db); + assert.deepEqual(repository.listByProject("project-1"), []); + assert.equal(repository.listByProject("missing"), null); + assert.equal( + repository.createNamed({ + projectId: "missing", + expectedRevision: 0, + name: "Unbekannt", + }), + null + ); + } finally { + context.close(); + } + }); +}); diff --git a/tests/project-state-snapshot.test.ts b/tests/project-state-snapshot.test.ts new file mode 100644 index 0000000..0ff636a --- /dev/null +++ b/tests/project-state-snapshot.test.ts @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + deserializeProjectStateSnapshot, + parseProjectStateSnapshot, + serializeProjectStateSnapshot, +} from "../src/domain/models/project-state-snapshot.model.js"; +import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js"; + +function minimalSnapshot() { + return { + schemaVersion: 1 as const, + project: { + id: "project-1", + name: "Projekt", + singlePhaseVoltageV: 230, + threePhaseVoltageV: 400, + }, + distributionBoards: [], + circuitLists: [], + circuitSections: [], + circuits: [], + projectDevices: [], + floors: [], + rooms: [], + }; +} + +describe("project state snapshot model", () => { + it("round-trips a versioned logical project state", () => { + const snapshot = parseProjectStateSnapshot(minimalSnapshot()); + assert.deepEqual( + deserializeProjectStateSnapshot( + serializeProjectStateSnapshot(snapshot) + ), + snapshot + ); + }); + + it("rejects duplicate ids and cross-project ownership", () => { + assert.throws( + () => + parseProjectStateSnapshot({ + ...minimalSnapshot(), + distributionBoards: [ + { + id: "board-1", + projectId: "project-1", + name: "UV 1", + }, + { + id: "board-1", + projectId: "project-1", + name: "UV 2", + }, + ], + }), + /duplicate distribution board ids/ + ); + assert.throws( + () => + parseProjectStateSnapshot({ + ...minimalSnapshot(), + floors: [ + { + id: "floor-1", + projectId: "project-2", + name: "EG", + sortOrder: 10, + }, + ], + }), + /floor belongs to a different project/ + ); + }); + + it("rejects invalid references and inconsistent reserve circuits", () => { + assert.throws( + () => + parseProjectStateSnapshot({ + ...minimalSnapshot(), + rooms: [ + { + id: "room-1", + projectId: "project-1", + floorId: "missing", + roomNumber: "001", + roomName: "Technik", + }, + ], + }), + /room floor reference is invalid/ + ); + assert.throws( + () => + parseProjectStateSnapshot({ + ...minimalSnapshot(), + circuits: [ + { + id: "circuit-1", + circuitListId: "missing", + sectionId: "missing", + equipmentIdentifier: "-1F1", + displayName: null, + sortOrder: 10, + protectionType: null, + protectionRatedCurrent: null, + protectionCharacteristic: null, + cableType: null, + cableCrossSection: null, + cableLength: null, + rcdAssignment: null, + terminalDesignation: null, + voltage: null, + controlRequirement: null, + status: null, + isReserve: true, + remark: null, + deviceRows: [], + }, + ], + }), + /circuit list reference is invalid/ + ); + }); +}); + +describe("named project snapshot request", () => { + it("normalizes bounded snapshot metadata", () => { + assert.deepEqual( + createNamedProjectSnapshotSchema.parse({ + expectedRevision: 12, + name: " Freigabe ", + description: " Stand vor Ausschreibung ", + }), + { + expectedRevision: 12, + name: "Freigabe", + description: "Stand vor Ausschreibung", + } + ); + }); + + it("rejects stale-shaped and excessive input", () => { + for (const input of [ + { expectedRevision: -1, name: "Stand" }, + { expectedRevision: 0, name: " " }, + { expectedRevision: 0, name: "x".repeat(101) }, + { + expectedRevision: 0, + name: "Stand", + description: "x".repeat(501), + }, + { + expectedRevision: 0, + name: "Stand", + unexpected: true, + }, + ]) { + assert.equal( + createNamedProjectSnapshotSchema.safeParse(input).success, + false + ); + } + }); +});