diff --git a/AGENTS.md b/AGENTS.md index 04d1493..1ee944a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,8 +220,10 @@ After saving, the row becomes linked to the new project device. ## Undo / Redo -Session-local undo/redo exists for structural and destructive operations. -Persistent project-wide undo/redo remains future work. +Session-local UI undo/redo exists for structural and destructive operations. +The server already persists immutable revisions and project-wide undo/redo +eligibility for the first internal commands. Public command execution, +server-side undo/redo endpoints and the UI cutover remain future work. Required operations: @@ -271,7 +273,7 @@ Users must be able to override sizing suggestions. ## Current Deferred Work -- persistent project revisions and undo/redo +- complete persistent project command coverage and the undo/redo UI cutover - named logical snapshots and restore - Revit/CSV/IFCGUID round-trip - full electrical sizing diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 0112963..392930b 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -7,7 +7,14 @@ All paths below are mounted below `/api`. There is no Consumer application API. Project responses from `GET /projects` and `GET /projects/:projectId` include `currentRevision`. It currently exposes the persisted revision foundation only; -no public history, undo or redo endpoint exists yet. +no public undo or redo command exists yet. + +### Project History State + +- `GET /projects/:projectId/history` + - returns `currentRevision`, `undoDepth`, `redoDepth` and the current top + change-set id for both persistent stacks + - is read-only; the editor does not consume it yet ## Circuit-First Endpoints diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 31977b1..90c911c 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -73,9 +73,12 @@ angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren intern Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und Revision gemeinsam committen beziehungsweise zurückrollen können. Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und -prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Die Stores sind noch -nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo ist daher -noch nicht verfügbar. +prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite, +persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und +verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über +`GET /api/projects/:projectId/history` lesbar. Die schreibenden Command-Stores +sind noch nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo +ist daher noch nicht verfügbar. ## Projektgeräte diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index a451217..ccdfe3d 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -160,16 +160,24 @@ Completed foundation: - integration tests apply the generated inverse as a new `undo` revision and verify rollback for stale revisions and late history failures on both update command types +- project-scoped relational undo/redo stacks persist eligible original change + sets independently from immutable audit revisions +- stack transitions enforce LIFO order, keep undo/redo revisions out of the + eligibility stacks and clear the redo branch on a new user command +- domain writes, audit revisions and stack transitions share one transaction; + a forced stack failure rolls all of them back +- `GET /api/projects/:projectId/history` exposes current revision, stack depths + and top change-set ids without enabling mutation endpoints -Remaining constraints before implementing history: +Remaining constraints before completing persistent history: - extend the concrete project-scoped command union and executors beyond the internal Circuit and CircuitDeviceRow update slices; the generic versioned command envelope is complete -- route domain writes and revision recording through one shared project - transaction boundary; the current revision repository only commits history - records and the counter together -- expose revision state and history eligibility through the application API +- route the remaining structural, synchronization and destructive domain + writes through the shared command, revision and stack transaction boundary +- add public command execution plus undo/redo endpoints; read-only revision and + stack eligibility state is already exposed - replace the session-local frontend command stack only after server-side command execution and inverse application are 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 7651cc7..dd22d1e 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -361,7 +361,7 @@ Tasks: - route domain writes and history records through one shared transaction - define concrete serializable domain command descriptions and executors outside React -- persist undo/redo eligibility on the server +- [x] persist undo/redo eligibility on the server - add named and periodic logical project snapshots - restore a historical snapshot as a new revision - keep database backups separate from logical history @@ -379,6 +379,12 @@ Implemented foundation: - an internal typed `circuit-device-row.update` command uses the same boundary, records derived ProjectDevice override metadata and rejects cross-project rows, device links and rooms +- relational project-wide undo/redo stacks persist eligible original change + sets, enforce LIFO order and clear redo on a new user command +- stack transitions are part of the domain/revision transaction and have + late-failure rollback coverage +- a read-only project history endpoint exposes revision, stack depths and top + change-set ids - revision metadata, change-set payloads and the project counter are committed in one SQLite transaction - a stale expected revision produces no history writes diff --git a/package.json b/package.json index 39bfb7d..d5e9f20 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-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.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-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.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-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/project-history.repository.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-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/project-history.repository.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/0013_project_history_stacks.sql b/src/db/migrations/0013_project_history_stacks.sql new file mode 100644 index 0000000..8fd8b30 --- /dev/null +++ b/src/db/migrations/0013_project_history_stacks.sql @@ -0,0 +1,11 @@ +CREATE TABLE `project_history_stack_entries` ( + `change_set_id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `stack` text NOT NULL, + `position` integer NOT NULL, + FOREIGN KEY (`change_set_id`) REFERENCES `project_change_sets`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "project_history_stack_entries_stack_check" CHECK("project_history_stack_entries"."stack" in ('undo', 'redo')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `project_history_stack_entries_position_unique` ON `project_history_stack_entries` (`project_id`,`stack`,`position`); diff --git a/src/db/migrations/meta/0013_snapshot.json b/src/db/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..9b28c31 --- /dev/null +++ b/src/db/migrations/meta/0013_snapshot.json @@ -0,0 +1,1694 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b4029fef-ed7b-47ed-a185-d151ad3f46da", + "prevId": "3f69cb15-3079-4bf5-96f7-3af62c8ac7e1", + "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": {} + }, + "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 a8ece89..63e5072 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1784833957929, "tag": "0012_project_revision_foundation", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1784835703230, + "tag": "0013_project_history_stacks", + "breakpoints": true } ] } diff --git a/src/db/repositories/circuit-device-row-project-command.repository.ts b/src/db/repositories/circuit-device-row-project-command.repository.ts index aac15e2..ebed605 100644 --- a/src/db/repositories/circuit-device-row-project-command.repository.ts +++ b/src/db/repositories/circuit-device-row-project-command.repository.ts @@ -21,6 +21,7 @@ import { toCircuitDeviceRowPatchValues, type CircuitDeviceRowPatchInput, } from "./circuit-device-row.persistence.js"; +import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { appendProjectRevision } from "./project-revision.persistence.js"; type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect; @@ -117,6 +118,12 @@ export class CircuitDeviceRowProjectCommandRepository forward: appliedForward, inverse, }); + applyProjectHistoryTransition(tx, { + projectId: input.projectId, + source: input.source, + recordedChangeSetId: revision.changeSetId, + targetChangeSetId: input.historyTargetChangeSetId, + }); return { revision, inverse }; }); diff --git a/src/db/repositories/circuit-project-command.repository.ts b/src/db/repositories/circuit-project-command.repository.ts index 46f4341..a5382ec 100644 --- a/src/db/repositories/circuit-project-command.repository.ts +++ b/src/db/repositories/circuit-project-command.repository.ts @@ -18,6 +18,7 @@ import { toCircuitPatchValues, type CircuitPatchPersistenceInput, } from "./circuit.persistence.js"; +import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { appendProjectRevision } from "./project-revision.persistence.js"; type CircuitRow = typeof circuits.$inferSelect; @@ -96,6 +97,12 @@ export class CircuitProjectCommandRepository forward: input.command, inverse, }); + applyProjectHistoryTransition(tx, { + projectId: input.projectId, + source: input.source, + recordedChangeSetId: revision.changeSetId, + targetChangeSetId: input.historyTargetChangeSetId, + }); return { revision, inverse }; }); diff --git a/src/db/repositories/project-history.persistence.ts b/src/db/repositories/project-history.persistence.ts new file mode 100644 index 0000000..5dd7628 --- /dev/null +++ b/src/db/repositories/project-history.persistence.ts @@ -0,0 +1,121 @@ +import { and, desc, eq, max } from "drizzle-orm"; +import type { ProjectRevisionSource } from "../../domain/ports/project-revision.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js"; + +export interface ApplyProjectHistoryTransitionInput { + projectId: string; + source: ProjectRevisionSource; + recordedChangeSetId: string; + targetChangeSetId?: string; +} + +export function applyProjectHistoryTransition( + database: AppDatabase, + input: ApplyProjectHistoryTransitionInput +) { + if (input.source === "user") { + database + .delete(projectHistoryStackEntries) + .where( + and( + eq(projectHistoryStackEntries.projectId, input.projectId), + eq(projectHistoryStackEntries.stack, "redo") + ) + ) + .run(); + push( + database, + input.projectId, + input.recordedChangeSetId, + "undo" + ); + return; + } + + if (input.source === "undo" || input.source === "redo") { + if (!input.targetChangeSetId) { + throw new Error( + `${input.source} requires a target change set.` + ); + } + const sourceStack = input.source; + const targetStack = input.source === "undo" ? "redo" : "undo"; + const top = database + .select({ + changeSetId: projectHistoryStackEntries.changeSetId, + }) + .from(projectHistoryStackEntries) + .where( + and( + eq(projectHistoryStackEntries.projectId, input.projectId), + eq(projectHistoryStackEntries.stack, sourceStack) + ) + ) + .orderBy(desc(projectHistoryStackEntries.position)) + .limit(1) + .get(); + if (!top || top.changeSetId !== input.targetChangeSetId) { + throw new Error( + `${input.source} target is not the top ${sourceStack} command.` + ); + } + + const nextPosition = getNextPosition( + database, + input.projectId, + targetStack + ); + const moved = database + .update(projectHistoryStackEntries) + .set({ stack: targetStack, position: nextPosition }) + .where( + and( + eq(projectHistoryStackEntries.projectId, input.projectId), + eq( + projectHistoryStackEntries.changeSetId, + input.targetChangeSetId + ) + ) + ) + .run(); + if (moved.changes !== 1) { + throw new Error("Project history stack changed during transition."); + } + } +} + +function push( + database: AppDatabase, + projectId: string, + changeSetId: string, + stack: "undo" | "redo" +) { + database + .insert(projectHistoryStackEntries) + .values({ + projectId, + changeSetId, + stack, + position: getNextPosition(database, projectId, stack), + }) + .run(); +} + +function getNextPosition( + database: AppDatabase, + projectId: string, + stack: "undo" | "redo" +) { + const current = database + .select({ position: max(projectHistoryStackEntries.position) }) + .from(projectHistoryStackEntries) + .where( + and( + eq(projectHistoryStackEntries.projectId, projectId), + eq(projectHistoryStackEntries.stack, stack) + ) + ) + .get(); + return (current?.position ?? 0) + 1; +} diff --git a/src/db/repositories/project-history.repository.ts b/src/db/repositories/project-history.repository.ts new file mode 100644 index 0000000..1a97a15 --- /dev/null +++ b/src/db/repositories/project-history.repository.ts @@ -0,0 +1,44 @@ +import { asc, eq } from "drizzle-orm"; +import type { + ProjectHistoryState, + ProjectHistoryStore, +} from "../../domain/ports/project-history.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js"; +import { projects } from "../schema/projects.js"; + +export class ProjectHistoryRepository implements ProjectHistoryStore { + constructor(private readonly database: AppDatabase) {} + + getState(projectId: string): ProjectHistoryState | null { + const project = this.database + .select({ currentRevision: projects.currentRevision }) + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (!project) { + return null; + } + + const entries = this.database + .select() + .from(projectHistoryStackEntries) + .where(eq(projectHistoryStackEntries.projectId, projectId)) + .orderBy( + asc(projectHistoryStackEntries.stack), + asc(projectHistoryStackEntries.position) + ) + .all(); + const undoEntries = entries.filter((entry) => entry.stack === "undo"); + const redoEntries = entries.filter((entry) => entry.stack === "redo"); + + return { + projectId, + currentRevision: project.currentRevision, + undoDepth: undoEntries.length, + redoDepth: redoEntries.length, + undoChangeSetId: undoEntries.at(-1)?.changeSetId ?? null, + redoChangeSetId: redoEntries.at(-1)?.changeSetId ?? null, + }; + } +} diff --git a/src/db/schema/project-history-stack-entries.ts b/src/db/schema/project-history-stack-entries.ts new file mode 100644 index 0000000..5d0938f --- /dev/null +++ b/src/db/schema/project-history-stack-entries.ts @@ -0,0 +1,35 @@ +import { sql } from "drizzle-orm"; +import { + check, + integer, + sqliteTable, + text, + unique, +} from "drizzle-orm/sqlite-core"; +import { projectChangeSets } from "./project-change-sets.js"; +import { projects } from "./projects.js"; + +export const projectHistoryStackEntries = sqliteTable( + "project_history_stack_entries", + { + changeSetId: text("change_set_id") + .primaryKey() + .references(() => projectChangeSets.id, { onDelete: "cascade" }), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + stack: text("stack").notNull(), + position: integer("position").notNull(), + }, + (table) => [ + check( + "project_history_stack_entries_stack_check", + sql`${table.stack} in ('undo', 'redo')` + ), + unique("project_history_stack_entries_position_unique").on( + table.projectId, + table.stack, + table.position + ), + ] +); diff --git a/src/domain/ports/circuit-device-row-project-command.store.ts b/src/domain/ports/circuit-device-row-project-command.store.ts index 125693d..a5fb363 100644 --- a/src/domain/ports/circuit-device-row-project-command.store.ts +++ b/src/domain/ports/circuit-device-row-project-command.store.ts @@ -10,6 +10,7 @@ export interface ExecuteCircuitDeviceRowUpdateCommandInput { source: ProjectRevisionSource; description?: string; actorId?: string; + historyTargetChangeSetId?: string; command: CircuitDeviceRowUpdateProjectCommand; } diff --git a/src/domain/ports/circuit-project-command.store.ts b/src/domain/ports/circuit-project-command.store.ts index e69664b..3fdc889 100644 --- a/src/domain/ports/circuit-project-command.store.ts +++ b/src/domain/ports/circuit-project-command.store.ts @@ -10,6 +10,7 @@ export interface ExecuteCircuitUpdateCommandInput { source: ProjectRevisionSource; description?: string; actorId?: string; + historyTargetChangeSetId?: string; command: CircuitUpdateProjectCommand; } diff --git a/src/domain/ports/project-history.store.ts b/src/domain/ports/project-history.store.ts new file mode 100644 index 0000000..7003104 --- /dev/null +++ b/src/domain/ports/project-history.store.ts @@ -0,0 +1,12 @@ +export interface ProjectHistoryState { + projectId: string; + currentRevision: number; + undoDepth: number; + redoDepth: number; + undoChangeSetId: string | null; + redoChangeSetId: string | null; +} + +export interface ProjectHistoryStore { + getState(projectId: string): ProjectHistoryState | null; +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index ae53081..d13178d 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -6,6 +6,15 @@ export interface ProjectDto { currentRevision: number; } +export interface ProjectHistoryStateDto { + projectId: string; + currentRevision: number; + undoDepth: number; + redoDepth: number; + undoChangeSetId: string | null; + redoChangeSetId: string | null; +} + export interface DistributionBoardDto { id: string; projectId: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 185a462..11e82f0 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -8,6 +8,7 @@ import type { FloorDto, GlobalDeviceDto, ProjectDeviceDto, + ProjectHistoryStateDto, ProjectDeviceDisconnectResultDto, ProjectDeviceSyncPreviewDto, ProjectDeviceSyncRestoreRowDto, @@ -54,6 +55,12 @@ export function getProject(projectId: string) { return request(`/api/projects/${projectId}`); } +export function getProjectHistory(projectId: string) { + return request( + `/api/projects/${projectId}/history` + ); +} + export function createProject(name: string) { return request("/api/projects", { method: "POST", diff --git a/src/server/controllers/project-history.controller.ts b/src/server/controllers/project-history.controller.ts new file mode 100644 index 0000000..060ffd4 --- /dev/null +++ b/src/server/controllers/project-history.controller.ts @@ -0,0 +1,18 @@ +import type { Request, Response } from "express"; +import { db } from "../../db/client.js"; +import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; + +const projectHistoryRepository = new ProjectHistoryRepository(db); + +export function getProjectHistory(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const state = projectHistoryRepository.getState(projectId); + if (!state) { + return res.status(404).json({ error: "Project not found" }); + } + return res.json(state); +} diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index f2fc216..37a7fa9 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -13,12 +13,14 @@ import { listCircuitListsByProject } from "../controllers/circuit-list.controlle import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js"; import { createRoom, listRoomsByProject } from "../controllers/room.controller.js"; import { getCircuitTree } from "../controllers/circuit-tree.controller.js"; +import { getProjectHistory } from "../controllers/project-history.controller.js"; export const projectRouter = Router(); projectRouter.get("/", listProjects); projectRouter.post("/", createProject); projectRouter.get("/:projectId", getProject); +projectRouter.get("/:projectId/history", getProjectHistory); projectRouter.put("/:projectId", updateProjectSettings); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); diff --git a/tests/circuit-device-row-project-command.repository.test.ts b/tests/circuit-device-row-project-command.repository.test.ts index 19444fc..a7b486c 100644 --- a/tests/circuit-device-row-project-command.repository.test.ts +++ b/tests/circuit-device-row-project-command.repository.test.ts @@ -228,6 +228,7 @@ describe("circuit device-row project-command repository", () => { projectId: "project-1", expectedRevision: 1, source: "undo", + historyTargetChangeSetId: forward.revision.changeSetId, command: forward.inverse, }); diff --git a/tests/circuit-project-command.repository.test.ts b/tests/circuit-project-command.repository.test.ts index 7cf0417..bc8a4f6 100644 --- a/tests/circuit-project-command.repository.test.ts +++ b/tests/circuit-project-command.repository.test.ts @@ -177,6 +177,7 @@ describe("circuit project-command repository", () => { projectId: "project-1", expectedRevision: 1, source: "undo", + historyTargetChangeSetId: forward.revision.changeSetId, description: "Stromkreisänderung rückgängig machen", command: forward.inverse, }); diff --git a/tests/project-history.repository.test.ts b/tests/project-history.repository.test.ts new file mode 100644 index 0000000..0c7594b --- /dev/null +++ b/tests/project-history.repository.test.ts @@ -0,0 +1,275 @@ +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 { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js"; +import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js"; +import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; +import { projectHistoryStackEntries } from "../src/db/schema/project-history-stack-entries.js"; +import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projects } from "../src/db/schema/projects.js"; +import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; +import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db + .insert(projects) + .values({ id: "project-1", name: "Test project" }) + .run(); + const board = new DistributionBoardRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-1", "UV-01"); + const section = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, board.id)) + .get(); + assert.ok(section); + context.db + .insert(circuits) + .values({ + id: "circuit-1", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F1", + displayName: "Bestand", + sortOrder: 10, + }) + .run(); + context.db + .insert(circuitDeviceRows) + .values({ + id: "row-1", + circuitId: "circuit-1", + sortOrder: 10, + name: "Leuchte", + displayName: "Leuchte", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }) + .run(); + return context; +} + +function getCircuitName(context: DatabaseContext) { + return context.db + .select({ displayName: circuits.displayName }) + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .get()?.displayName; +} + +function getRowQuantity(context: DatabaseContext) { + return context.db + .select({ quantity: circuitDeviceRows.quantity }) + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get()?.quantity; +} + +describe("project history repository", () => { + it("persists project-wide undo and redo stacks across repository instances", () => { + const context = createTestDatabase(); + try { + const circuitsStore = new CircuitProjectCommandRepository(context.db); + const rowsStore = new CircuitDeviceRowProjectCommandRepository(context.db); + const circuitCommand = createCircuitUpdateProjectCommand("circuit-1", { + displayName: "Neu", + }); + const circuitUpdate = circuitsStore.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: circuitCommand, + }); + const rowCommand = createCircuitDeviceRowUpdateProjectCommand("row-1", { + quantity: 2, + }); + const rowUpdate = rowsStore.executeUpdate({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: rowCommand, + }); + + const reloadedHistory = new ProjectHistoryRepository(context.db); + assert.deepEqual(reloadedHistory.getState("project-1"), { + projectId: "project-1", + currentRevision: 2, + undoDepth: 2, + redoDepth: 0, + undoChangeSetId: rowUpdate.revision.changeSetId, + redoChangeSetId: null, + }); + + assert.throws( + () => + circuitsStore.executeUpdate({ + projectId: "project-1", + expectedRevision: 2, + source: "undo", + historyTargetChangeSetId: circuitUpdate.revision.changeSetId, + command: circuitUpdate.inverse, + }), + /not the top undo command/ + ); + assert.equal(getCircuitName(context), "Neu"); + assert.equal( + context.db + .select() + .from(projects) + .where(eq(projects.id, "project-1")) + .get()?.currentRevision, + 2 + ); + + rowsStore.executeUpdate({ + projectId: "project-1", + expectedRevision: 2, + source: "undo", + historyTargetChangeSetId: rowUpdate.revision.changeSetId, + command: rowUpdate.inverse, + }); + assert.equal(getRowQuantity(context), 1); + assert.deepEqual(reloadedHistory.getState("project-1"), { + projectId: "project-1", + currentRevision: 3, + undoDepth: 1, + redoDepth: 1, + undoChangeSetId: circuitUpdate.revision.changeSetId, + redoChangeSetId: rowUpdate.revision.changeSetId, + }); + + circuitsStore.executeUpdate({ + projectId: "project-1", + expectedRevision: 3, + source: "undo", + historyTargetChangeSetId: circuitUpdate.revision.changeSetId, + command: circuitUpdate.inverse, + }); + assert.equal(getCircuitName(context), "Bestand"); + assert.deepEqual(reloadedHistory.getState("project-1"), { + projectId: "project-1", + currentRevision: 4, + undoDepth: 0, + redoDepth: 2, + undoChangeSetId: null, + redoChangeSetId: circuitUpdate.revision.changeSetId, + }); + + circuitsStore.executeUpdate({ + projectId: "project-1", + expectedRevision: 4, + source: "redo", + historyTargetChangeSetId: circuitUpdate.revision.changeSetId, + command: circuitCommand, + }); + assert.equal(getCircuitName(context), "Neu"); + assert.deepEqual(reloadedHistory.getState("project-1"), { + projectId: "project-1", + currentRevision: 5, + undoDepth: 1, + redoDepth: 1, + undoChangeSetId: circuitUpdate.revision.changeSetId, + redoChangeSetId: rowUpdate.revision.changeSetId, + }); + + const branchedRowUpdate = rowsStore.executeUpdate({ + projectId: "project-1", + expectedRevision: 5, + source: "user", + command: createCircuitDeviceRowUpdateProjectCommand("row-1", { + quantity: 3, + }), + }); + assert.equal(getRowQuantity(context), 3); + assert.deepEqual(reloadedHistory.getState("project-1"), { + projectId: "project-1", + currentRevision: 6, + undoDepth: 2, + redoDepth: 0, + undoChangeSetId: branchedRowUpdate.revision.changeSetId, + redoChangeSetId: null, + }); + assert.equal(context.db.select().from(projectRevisions).all().length, 6); + assert.equal(context.db.select().from(projectChangeSets).all().length, 6); + assert.equal( + context.db.select().from(projectHistoryStackEntries).all().length, + 2 + ); + } finally { + context.close(); + } + }); + + it("rolls back domain and revision writes when stack persistence fails", () => { + const context = createTestDatabase(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_history_stack_insert + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced history stack failure'); + END; + `); + const store = new CircuitProjectCommandRepository(context.db); + + assert.throws( + () => + store.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitUpdateProjectCommand("circuit-1", { + displayName: "Darf nicht bleiben", + }), + }), + /forced history stack failure/ + ); + + assert.equal(getCircuitName(context), "Bestand"); + assert.deepEqual( + new ProjectHistoryRepository(context.db).getState("project-1"), + { + projectId: "project-1", + currentRevision: 0, + undoDepth: 0, + redoDepth: 0, + undoChangeSetId: null, + redoChangeSetId: null, + } + ); + assert.equal(context.db.select().from(projectRevisions).all().length, 0); + assert.equal(context.db.select().from(projectChangeSets).all().length, 0); + } finally { + context.close(); + } + }); + + it("returns null for an unknown project", () => { + const context = createTestDatabase(); + try { + assert.equal( + new ProjectHistoryRepository(context.db).getState("missing"), + null + ); + } finally { + context.close(); + } + }); +});