From 903d9774433409c48b4c5a4d7cdcb215ccc455a8 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 23 Jul 2026 21:17:48 +0200 Subject: [PATCH] Add project revision foundation --- docs/circuit-list-editor-api.md | 4 + docs/current-architecture.md | 9 +- ...history-and-external-model-architecture.md | 14 +- docs/spec/07-implementation-phases-todo.md | 23 +- package.json | 4 +- .../0012_project_revision_foundation.sql | 24 + src/db/migrations/meta/0012_snapshot.json | 1614 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + .../project-revision.repository.ts | 123 ++ src/db/repositories/project.repository.ts | 1 + src/db/schema/project-change-sets.ts | 19 + src/db/schema/project-revisions.ts | 23 + src/db/schema/projects.ts | 1 + src/domain/ports/project-revision.store.ts | 43 + src/frontend/types.ts | 1 + tests/project-revision.repository.test.ts | 151 ++ 16 files changed, 2052 insertions(+), 9 deletions(-) create mode 100644 src/db/migrations/0012_project_revision_foundation.sql create mode 100644 src/db/migrations/meta/0012_snapshot.json create mode 100644 src/db/repositories/project-revision.repository.ts create mode 100644 src/db/schema/project-change-sets.ts create mode 100644 src/db/schema/project-revisions.ts create mode 100644 src/domain/ports/project-revision.store.ts create mode 100644 tests/project-revision.repository.test.ts diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 9d94a49..0112963 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -5,6 +5,10 @@ The circuit-first editor uses tree, circuit, row and project-device endpoints. 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. + ## Circuit-First Endpoints ### Distribution Board Setup diff --git a/docs/current-architecture.md b/docs/current-architecture.md index ec79ed6..76721e0 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -62,9 +62,12 @@ liegt über einen Host-Mount außerhalb des Containers. 6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise Viewport soweit möglich wieder her. -Die React-Historie ist derzeit sitzungslokal. Persistente Projektrevisionen, -optimistische Revisionsprüfungen und serverseitiges Undo/Redo sind die nächste -Architekturphase. +Die React-Historie ist derzeit sitzungslokal. Das Datenmodell besitzt bereits +einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set- +Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch +und atomar fortschreiben. Bestehende Fachoperationen sind aber noch nicht an +diese Grenze angeschlossen; 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 c4eea7d..da4be4d 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -137,11 +137,23 @@ Completed foundation: - database backup and independent restore verification are automated - the Consumer application path is removed and stable domain IDs are preserved - editor grid projection and safety rules are separated from React rendering +- projects carry a monotonic `currentRevision` counter starting at zero +- project revision metadata and versioned forward/inverse change-set payloads + have separate persistence entities +- the SQLite revision repository advances the project counter and stores both + history records atomically +- revision appends use an expected-revision compare-and-set and reject stale + callers without writing partial history Remaining constraints before implementing history: - model project-scoped command descriptions independently from React callbacks -- route all future history-enabled mutations through one project revision boundary +- 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 +- 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 - keep database backup/restore checks separate from project history tests diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 871e0a3..9f3baa6 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -354,14 +354,31 @@ Persist project-wide change history and restore points across reloads and applic Tasks: -- add project-scoped monotonic revisions -- record immutable server-side change sets with before/after state -- add optimistic revision checks for stale commands +- [x] add project-scoped monotonic revision storage +- [x] add immutable append-only server-side change-set storage with versioned + forward/inverse payloads +- [x] add optimistic checks when appending a revision +- route domain writes and history records through one shared transaction +- model serializable domain command descriptions outside React - 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 +Implemented foundation: + +- projects start at revision zero and advance monotonically +- each revision has one separately stored logical change set +- revision metadata, change-set payloads and the project counter are committed + in one SQLite transaction +- a stale expected revision produces no history writes +- integration coverage verifies sequential revisions, stale-command rejection + and rollback after a forced late persistence failure + +The current editor operations are not connected to this history boundary yet. +Undo/redo therefore remains session-local until the remaining tasks are +implemented. + Acceptance criteria: - undo/redo remains available after a page reload diff --git a/package.json b/package.json index 9861577..579f7af 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/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/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-revision.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-revision.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/0012_project_revision_foundation.sql b/src/db/migrations/0012_project_revision_foundation.sql new file mode 100644 index 0000000..7df8d19 --- /dev/null +++ b/src/db/migrations/0012_project_revision_foundation.sql @@ -0,0 +1,24 @@ +CREATE TABLE `project_change_sets` ( + `id` text PRIMARY KEY NOT NULL, + `project_revision_id` text NOT NULL, + `command_type` text NOT NULL, + `payload_schema_version` integer NOT NULL, + `forward_payload_json` text NOT NULL, + `inverse_payload_json` text NOT NULL, + FOREIGN KEY (`project_revision_id`) REFERENCES `project_revisions`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `project_change_sets_revision_unique` ON `project_change_sets` (`project_revision_id`);--> statement-breakpoint +CREATE TABLE `project_revisions` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `revision_number` integer NOT NULL, + `created_at_iso` text NOT NULL, + `actor_id` text, + `source` text NOT NULL, + `description` text, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `project_revisions_project_number_unique` ON `project_revisions` (`project_id`,`revision_number`);--> statement-breakpoint +ALTER TABLE `projects` ADD `current_revision` integer DEFAULT 0 NOT NULL; diff --git a/src/db/migrations/meta/0012_snapshot.json b/src/db/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..8d689f0 --- /dev/null +++ b/src/db/migrations/meta/0012_snapshot.json @@ -0,0 +1,1614 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3f69cb15-3079-4bf5-96f7-3af62c8ac7e1", + "prevId": "863215c4-ed6e-4ec5-8c48-161054031da2", + "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_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 a8d947f..a8ece89 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1784832541202, "tag": "0011_project_device_canonical_fields", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1784833957929, + "tag": "0012_project_revision_foundation", + "breakpoints": true } ] } diff --git a/src/db/repositories/project-revision.repository.ts b/src/db/repositories/project-revision.repository.ts new file mode 100644 index 0000000..d68a633 --- /dev/null +++ b/src/db/repositories/project-revision.repository.ts @@ -0,0 +1,123 @@ +import crypto from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import type { + AppendProjectRevisionInput, + AppendedProjectRevision, + ProjectRevisionStore, +} from "../../domain/ports/project-revision.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { projectChangeSets } from "../schema/project-change-sets.js"; +import { projectRevisions } from "../schema/project-revisions.js"; +import { projects } from "../schema/projects.js"; + +export class ProjectRevisionConflictError extends Error { + constructor( + readonly projectId: string, + readonly expectedRevision: number, + readonly actualRevision: number | null + ) { + super( + actualRevision === null + ? `Project ${projectId} does not exist.` + : `Project ${projectId} is at revision ${actualRevision}, expected ${expectedRevision}.` + ); + this.name = "ProjectRevisionConflictError"; + } +} + +export class ProjectRevisionRepository implements ProjectRevisionStore { + constructor(private readonly database: AppDatabase) {} + + getCurrentRevision(projectId: string) { + const project = this.database + .select({ currentRevision: projects.currentRevision }) + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + return project?.currentRevision ?? null; + } + + append(input: AppendProjectRevisionInput): AppendedProjectRevision { + this.validateInput(input); + + const revisionId = crypto.randomUUID(); + const changeSetId = crypto.randomUUID(); + const revisionNumber = input.expectedRevision + 1; + const createdAtIso = new Date().toISOString(); + + return this.database.transaction((tx) => { + const revisionUpdate = tx + .update(projects) + .set({ currentRevision: revisionNumber }) + .where( + and( + eq(projects.id, input.projectId), + eq(projects.currentRevision, input.expectedRevision) + ) + ) + .run(); + + if (revisionUpdate.changes !== 1) { + const current = tx + .select({ currentRevision: projects.currentRevision }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + throw new ProjectRevisionConflictError( + input.projectId, + input.expectedRevision, + current?.currentRevision ?? null + ); + } + + tx.insert(projectRevisions) + .values({ + id: revisionId, + projectId: input.projectId, + revisionNumber, + createdAtIso, + actorId: input.actorId, + source: input.source, + description: input.description, + }) + .run(); + + tx.insert(projectChangeSets) + .values({ + id: changeSetId, + projectRevisionId: revisionId, + commandType: input.commandType, + payloadSchemaVersion: input.forward.schemaVersion, + forwardPayloadJson: JSON.stringify(input.forward.data), + inversePayloadJson: JSON.stringify(input.inverse.data), + }) + .run(); + + return { + revisionId, + changeSetId, + projectId: input.projectId, + revisionNumber, + createdAtIso, + }; + }); + } + + private validateInput(input: AppendProjectRevisionInput) { + if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) { + throw new Error("Expected project revision must be a non-negative integer."); + } + if (!input.commandType.trim()) { + throw new Error("Project revision command type is required."); + } + if ( + !Number.isSafeInteger(input.forward.schemaVersion) || + input.forward.schemaVersion < 1 || + input.forward.schemaVersion !== input.inverse.schemaVersion + ) { + throw new Error( + "Forward and inverse payloads require the same positive schema version." + ); + } + } +} diff --git a/src/db/repositories/project.repository.ts b/src/db/repositories/project.repository.ts index d5e6b25..914049b 100644 --- a/src/db/repositories/project.repository.ts +++ b/src/db/repositories/project.repository.ts @@ -19,6 +19,7 @@ export class ProjectRepository { name: input.name, singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230, threePhaseVoltageV: input.threePhaseVoltageV ?? 400, + currentRevision: 0, }; await db.insert(projects).values(project); return project; diff --git a/src/db/schema/project-change-sets.ts b/src/db/schema/project-change-sets.ts new file mode 100644 index 0000000..538817d --- /dev/null +++ b/src/db/schema/project-change-sets.ts @@ -0,0 +1,19 @@ +import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { projectRevisions } from "./project-revisions.js"; + +export const projectChangeSets = sqliteTable( + "project_change_sets", + { + id: text("id").primaryKey(), + projectRevisionId: text("project_revision_id") + .notNull() + .references(() => projectRevisions.id, { onDelete: "cascade" }), + commandType: text("command_type").notNull(), + payloadSchemaVersion: integer("payload_schema_version").notNull(), + forwardPayloadJson: text("forward_payload_json").notNull(), + inversePayloadJson: text("inverse_payload_json").notNull(), + }, + (table) => [ + unique("project_change_sets_revision_unique").on(table.projectRevisionId), + ] +); diff --git a/src/db/schema/project-revisions.ts b/src/db/schema/project-revisions.ts new file mode 100644 index 0000000..4601c7b --- /dev/null +++ b/src/db/schema/project-revisions.ts @@ -0,0 +1,23 @@ +import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { projects } from "./projects.js"; + +export const projectRevisions = sqliteTable( + "project_revisions", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + revisionNumber: integer("revision_number").notNull(), + createdAtIso: text("created_at_iso").notNull(), + actorId: text("actor_id"), + source: text("source").notNull(), + description: text("description"), + }, + (table) => [ + unique("project_revisions_project_number_unique").on( + table.projectId, + table.revisionNumber + ), + ] +); diff --git a/src/db/schema/projects.ts b/src/db/schema/projects.ts index 8794ba9..b0d27b1 100644 --- a/src/db/schema/projects.ts +++ b/src/db/schema/projects.ts @@ -5,4 +5,5 @@ export const projects = sqliteTable("projects", { name: text("name").notNull(), singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230), threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400), + currentRevision: integer("current_revision").notNull().default(0), }); diff --git a/src/domain/ports/project-revision.store.ts b/src/domain/ports/project-revision.store.ts new file mode 100644 index 0000000..d259294 --- /dev/null +++ b/src/domain/ports/project-revision.store.ts @@ -0,0 +1,43 @@ +export type ProjectRevisionSource = + | "user" + | "undo" + | "redo" + | "restore" + | "migration"; + +export type ProjectChangeJsonValue = + | null + | boolean + | number + | string + | ProjectChangeJsonValue[] + | { [key: string]: ProjectChangeJsonValue }; + +export interface ProjectChangePayload { + schemaVersion: number; + data: ProjectChangeJsonValue; +} + +export interface AppendProjectRevisionInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + commandType: string; + description?: string; + actorId?: string; + forward: ProjectChangePayload; + inverse: ProjectChangePayload; +} + +export interface AppendedProjectRevision { + revisionId: string; + changeSetId: string; + projectId: string; + revisionNumber: number; + createdAtIso: string; +} + +export interface ProjectRevisionStore { + getCurrentRevision(projectId: string): number | null; + append(input: AppendProjectRevisionInput): AppendedProjectRevision; +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 2395e99..ae53081 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -3,6 +3,7 @@ export interface ProjectDto { name: string; singlePhaseVoltageV: number; threePhaseVoltageV: number; + currentRevision: number; } export interface DistributionBoardDto { diff --git a/tests/project-revision.repository.test.ts b/tests/project-revision.repository.test.ts new file mode 100644 index 0000000..bb8bda4 --- /dev/null +++ b/tests/project-revision.repository.test.ts @@ -0,0 +1,151 @@ +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 { + ProjectRevisionConflictError, + ProjectRevisionRepository, +} from "../src/db/repositories/project-revision.repository.js"; +import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; +import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projects } from "../src/db/schema/projects.js"; + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run(); + return context; +} + +function appendTestRevision( + repository: ProjectRevisionRepository, + expectedRevision: number +) { + return repository.append({ + projectId: "project-1", + expectedRevision, + source: "user", + commandType: "circuit.update", + description: "Stromkreis bearbeiten", + actorId: "test-user", + forward: { + schemaVersion: 1, + data: { circuitId: "circuit-1", displayName: "Neu" }, + }, + inverse: { + schemaVersion: 1, + data: { circuitId: "circuit-1", displayName: "Alt" }, + }, + }); +} + +describe("project revision repository", () => { + it("appends immutable revision metadata and payloads with a monotonic number", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectRevisionRepository(context.db); + + assert.equal(repository.getCurrentRevision("project-1"), 0); + const appended = appendTestRevision(repository, 0); + + assert.equal(appended.projectId, "project-1"); + assert.equal(appended.revisionNumber, 1); + assert.equal(repository.getCurrentRevision("project-1"), 1); + + const revision = context.db + .select() + .from(projectRevisions) + .where(eq(projectRevisions.id, appended.revisionId)) + .get(); + const changeSet = context.db + .select() + .from(projectChangeSets) + .where(eq(projectChangeSets.id, appended.changeSetId)) + .get(); + + assert.deepEqual(revision, { + id: appended.revisionId, + projectId: "project-1", + revisionNumber: 1, + createdAtIso: appended.createdAtIso, + actorId: "test-user", + source: "user", + description: "Stromkreis bearbeiten", + }); + assert.deepEqual(changeSet, { + id: appended.changeSetId, + projectRevisionId: appended.revisionId, + commandType: "circuit.update", + payloadSchemaVersion: 1, + forwardPayloadJson: JSON.stringify({ + circuitId: "circuit-1", + displayName: "Neu", + }), + inversePayloadJson: JSON.stringify({ + circuitId: "circuit-1", + displayName: "Alt", + }), + }); + + const next = appendTestRevision(repository, 1); + assert.equal(next.revisionNumber, 2); + assert.equal(repository.getCurrentRevision("project-1"), 2); + } finally { + context.close(); + } + }); + + it("rejects a stale expected revision without writing history", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectRevisionRepository(context.db); + appendTestRevision(repository, 0); + + assert.throws( + () => appendTestRevision(repository, 0), + (error) => + error instanceof ProjectRevisionConflictError && + error.expectedRevision === 0 && + error.actualRevision === 1 + ); + + assert.equal(repository.getCurrentRevision("project-1"), 1); + assert.equal(context.db.select().from(projectRevisions).all().length, 1); + assert.equal(context.db.select().from(projectChangeSets).all().length, 1); + } finally { + context.close(); + } + }); + + it("rolls back the project counter and revision when change-set storage fails", () => { + const context = createTestDatabase(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_project_change_set_insert + BEFORE INSERT ON project_change_sets + BEGIN + SELECT RAISE(ABORT, 'forced change-set failure'); + END; + `); + const repository = new ProjectRevisionRepository(context.db); + + assert.throws( + () => appendTestRevision(repository, 0), + /forced change-set failure/ + ); + + assert.equal(repository.getCurrentRevision("project-1"), 0); + assert.equal(context.db.select().from(projectRevisions).all().length, 0); + assert.equal(context.db.select().from(projectChangeSets).all().length, 0); + } finally { + context.close(); + } + }); +});