diff --git a/AGENTS.md b/AGENTS.md index da34ece..e05ddf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -237,6 +237,9 @@ not change the project revision or undo/redo stacks. Restoring a server-stored snapshot verifies its checksum and the current-state hash, replaces supported project data atomically and records a new `restore` revision with a complete inverse command. Restore can therefore be undone and redone after a restart. +The central revision boundary creates an automatic logical snapshot after each +25 new revisions and retains only the newest 12 automatic snapshots per +project. Named snapshots are never removed by this retention policy. The project page exposes persistent project-wide Undo/Redo in the header of an initially collapsed German snapshot/timeline UI, with explicit restore confirmation and cursor-based loading of older revision metadata. @@ -314,7 +317,6 @@ Users must be able to override sizing suggestions. ## Current Deferred Work -- periodic snapshot and retention policy - Revit/CSV/IFCGUID round-trip - full electrical sizing - multi-user/PostgreSQL operation diff --git a/README.md b/README.md index 207e9e0..520edd2 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt. Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment, -automatische periodische Sicherungspunkte und der Revit-/IFCGUID-Datenaustausch -sind noch nicht implementiert. +Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht +implementiert. ## Unterstützter Arbeitsablauf @@ -20,6 +20,7 @@ sind noch nicht implementiert. - BMKs stabil halten und nur auf ausdrücklichen Befehl neu nummerieren - Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen - benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen +- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen ## Technik diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index eb67c16..1caf6e8 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -33,7 +33,8 @@ this value for optimistic concurrency checks. - executes the eligible inverse or forward command as a new auditable revision - `GET /projects/:projectId/snapshots` - - lists named logical snapshot metadata without returning payload JSON + - lists named and automatic logical snapshot metadata, including `kind`, + without returning payload JSON - `POST /projects/:projectId/snapshots` - body: `{ "expectedRevision": 12, "name": "Vor Ausschreibung", "description": "Optional" }` @@ -44,6 +45,9 @@ this value for optimistic concurrency checks. - snapshot creation does not increment the project revision or alter undo/redo stacks - duplicate names and stale revisions return `409` + - the central revision boundary additionally creates `automatic` snapshots + after 25 further revisions and retains the newest 12 per project; named + snapshots are never removed by this policy - `POST /projects/:projectId/snapshots/:snapshotId/restore` - body: `{ "expectedRevision": 13 }` - verifies the stored payload checksum and atomically restores the complete diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 1732e20..4913da5 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -6,9 +6,9 @@ eligibility is restored from server history after a page reload. - The project page exposes the paginated revision timeline; the circuit editor itself currently exposes only the next eligible Undo/Redo actions. -- Named logical project snapshots can be created, listed and restored through - the project page with persistent Undo/Redo. Automatic periodic snapshots are - not implemented yet. +- Named and automatic logical project snapshots can be listed and restored + through the project page with persistent Undo/Redo. Automatic snapshots are + created every 25 revisions and only their newest 12 entries are retained. - No Revit/CSV/IFCGUID import and export workflow is implemented yet. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - The global device library supports basic CRUD and copy operations, but has no diff --git a/docs/current-architecture.md b/docs/current-architecture.md index a14ee45..5532123 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -69,24 +69,24 @@ besitzt einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach -einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und -bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze -angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren interne -Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und -Revision gemeinsam committen beziehungsweise zurückrollen können. +einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten +Circuit-, Gerätezeilen-, Projektgeräte- und Projekteinstellungsänderungen +verwenden typisierte Command-Stores, die Fachänderung, automatisch erzeugtes +inverses Kommando, Revision und Historienstapel gemeinsam committen +beziehungsweise zurückrollen. Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und 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 unveränderliche, absteigend paginierte Revisions-Timeline ist ohne Befehls-Payloads über -`GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler Dispatcher führt -die unterstützten Typen `circuit.update` und `circuit-device-row.update` über -öffentliche Command-, Undo- und Redo-Endpunkte aus. Zusätzlich sind -`circuit-device-row.insert` und `circuit-device-row.delete` als atomare -Strukturkommandos vorhanden. Beim Löschen wird die vollständige Zeile im -inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte -wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen +`GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler +Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über +öffentliche Command-, Undo- und Redo-Endpunkte aus. +`circuit-device-row.insert` und `circuit-device-row.delete` sind atomare +Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen +Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte wiederherstellt. +`circuit.insert` und `circuit.delete` behandeln einen Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block. Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen @@ -109,6 +109,12 @@ Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte, Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots. Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel. +`kind` unterscheidet benannte und automatische Stände. Die zentrale +Revisionspersistenz erzeugt nach jeweils 25 weiteren Projektänderungen +transaktional einen automatischen Stand. Pro Projekt bleiben die neuesten 12 +automatischen Stände erhalten; ältere automatische Stände werden in derselben +Transaktion entfernt. Benannte Stände und die unveränderliche Revisionshistorie +sind von dieser Aufbewahrung ausdrücklich ausgeschlossen. `POST /api/projects/:projectId/snapshots/:snapshotId/restore` prüft Payload, Prüfsumme, erwartete Revision und den unmittelbar zuvor gelesenen Projektzustand. Der Restore ersetzt alle unterstützten Projektdaten in einer @@ -216,7 +222,6 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter. ## Noch nicht unterstützt -- automatische periodische Projekt-Snapshots und Aufbewahrungsrichtlinie - Mehrbenutzerbetrieb und Konfliktauflösung - Revit-/CSV-/IFCGUID-Round-trip - vollständige elektrische Dimensionierung diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 4dfac2f..d0624b2 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -101,6 +101,17 @@ Implemented named-snapshot foundation: explicit inline restore confirmation and cursor-based loading of older revisions; a successful restore reloads all project read models +Implemented automatic-snapshot policy: + +- `project_snapshots.kind` distinguishes `named` from `automatic` +- the central revision boundary captures the complete post-command state after + every 25 additional project revisions +- the newest 12 automatic snapshots per project are retained +- retention never removes named snapshots, immutable revisions or physical + database backups +- automatic capture and retention run inside the same transaction as the + triggering project revision + ## Revit / CSV / IFCGUID Round-Trip Direction External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits. @@ -258,7 +269,6 @@ Completed foundation: Remaining constraints before completing project version history: -- define and implement a periodic snapshot and retention policy - extend project-scoped commands only when further project mutations are deliberately added to history; the generic versioned envelope is complete - do not model IFCGUID as an overloaded circuit equipment identifier @@ -266,7 +276,6 @@ Remaining constraints before completing project version history: ## Deferred Decisions -- exact snapshot frequency and retention policy - whether large snapshot payloads remain in PostgreSQL or move to object storage - branch visualization after undo followed by new edits - user/role model and actor attribution diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index a3ccbe3..26397bf 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -368,7 +368,7 @@ Tasks: command payloads - [x] add explicitly named logical project snapshots with schema-versioned payloads and integrity checksums -- add periodic logical project snapshots +- [x] add periodic logical project snapshots with a bounded retention policy - [x] restore a historical snapshot as a new auditable revision with persistent undo/redo - [x] expose named snapshots, confirmed restore and the paginated revision @@ -401,6 +401,9 @@ Implemented foundation: - named snapshot Create/List endpoints persist complete project-scoped runtime state with the source revision and SHA-256 without changing revision or undo/redo stacks +- the central revision boundary captures an `automatic` snapshot after every + 25 new revisions and retains the newest 12 automatic snapshots per project + without deleting named snapshots or immutable revisions - the project page lists, creates and explicitly restores named snapshots and displays paginated revision metadata without exposing command payloads - the version-card header reads persisted undo/redo eligibility and executes diff --git a/package.json b/package.json index 905df35..2240ada 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/migrations/0015_modern_madame_masque.sql b/src/db/migrations/0015_modern_madame_masque.sql new file mode 100644 index 0000000..2007787 --- /dev/null +++ b/src/db/migrations/0015_modern_madame_masque.sql @@ -0,0 +1,2 @@ +ALTER TABLE `project_snapshots` ADD `kind` text DEFAULT 'named' NOT NULL;--> statement-breakpoint +CREATE INDEX `project_snapshots_project_kind_revision_idx` ON `project_snapshots` (`project_id`,`kind`,`source_revision`); \ No newline at end of file diff --git a/src/db/migrations/meta/0015_snapshot.json b/src/db/migrations/meta/0015_snapshot.json new file mode 100644 index 0000000..e5ac2bf --- /dev/null +++ b/src/db/migrations/meta/0015_snapshot.json @@ -0,0 +1,1822 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3248cb0b-5512-43d3-8193-e7218cb8ae66", + "prevId": "bd9bc99c-0cc0-44e3-bff0-819b1cbbf8ac", + "tables": { + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_consumer_id": { + "name": "legacy_consumer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "protection_type": { + "name": "protection_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protection_rated_current": { + "name": "protection_rated_current", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protection_characteristic": { + "name": "protection_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "consumers": { + "name": "consumers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_device_id": { + "name": "project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_linked_to_device": { + "name": "is_linked_to_device", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_number": { + "name": "circuit_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trade_or_cost_group": { + "name": "trade_or_cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protection_type": { + "name": "protection_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protection_rated_current": { + "name": "protection_rated_current", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protection_characteristic": { + "name": "protection_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "consumers_project_id_projects_id_fk": { + "name": "consumers_project_id_projects_id_fk", + "tableFrom": "consumers", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "consumers_distribution_board_id_distribution_boards_id_fk": { + "name": "consumers_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "consumers", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "consumers_circuit_list_id_circuit_lists_id_fk": { + "name": "consumers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "consumers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "consumers_project_device_id_project_devices_id_fk": { + "name": "consumers_project_device_id_project_devices_id_fk", + "tableFrom": "consumers", + "tableTo": "project_devices", + "columnsFrom": [ + "project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "consumers_room_id_rooms_id_fk": { + "name": "consumers_room_id_rooms_id_fk", + "tableFrom": "consumers", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_boards_project_id_projects_id_fk": { + "name": "distribution_boards_project_id_projects_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "floors": { + "name": "floors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_consumer_circuit_migrations": { + "name": "legacy_consumer_circuit_migrations", + "columns": { + "consumer_id": { + "name": "consumer_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "legacy_consumer_circuit_migrations_circuit_id_circuits_id_fk": { + "name": "legacy_consumer_circuit_migrations_circuit_id_circuits_id_fk", + "tableFrom": "legacy_consumer_circuit_migrations", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "legacy_consumer_circuit_migrations_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "legacy_consumer_circuit_migrations_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "legacy_consumer_circuit_migrations", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "legacy_consumer_circuit_migrations_circuit_list_id_circuit_lists_id_fk": { + "name": "legacy_consumer_circuit_migrations_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "legacy_consumer_circuit_migrations", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_consumer_migration_reports": { + "name": "legacy_consumer_migration_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_consumer_count": { + "name": "legacy_consumer_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_circuit_count": { + "name": "created_circuit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_device_row_count": { + "name": "created_device_row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duplicate_grouped_count": { + "name": "duplicate_grouped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generated_identifier_count": { + "name": "generated_identifier_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unassigned_row_count": { + "name": "unassigned_row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "warnings_json": { + "name": "warnings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generated_identifiers_json": { + "name": "generated_identifiers_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duplicate_groups_json": { + "name": "duplicate_groups_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "legacy_consumer_migration_reports_list_unique": { + "name": "legacy_consumer_migration_reports_list_unique", + "columns": [ + "circuit_list_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "legacy_consumer_migration_reports_circuit_list_id_circuit_lists_id_fk": { + "name": "legacy_consumer_migration_reports_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "legacy_consumer_migration_reports", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "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 eb554ad..152562d 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1785009799979, "tag": "0014_project_snapshots", "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1785014383032, + "tag": "0015_modern_madame_masque", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/automatic-project-snapshot.persistence.ts b/src/db/repositories/automatic-project-snapshot.persistence.ts new file mode 100644 index 0000000..d3385b4 --- /dev/null +++ b/src/db/repositories/automatic-project-snapshot.persistence.ts @@ -0,0 +1,111 @@ +import crypto from "node:crypto"; +import { and, desc, eq, inArray } from "drizzle-orm"; +import { + automaticProjectSnapshotIntervalRevisions, + automaticProjectSnapshotRetentionCount, + shouldCaptureAutomaticProjectSnapshot, +} from "../../domain/models/project-snapshot-policy.model.js"; +import { projectStateSnapshotSchemaVersion } from "../../domain/models/project-state-snapshot.model.js"; +import type { ProjectSnapshotMetadata } from "../../domain/ports/project-snapshot.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { projectSnapshots } from "../schema/project-snapshots.js"; +import { readProjectStateSnapshot } from "./project-state-snapshot.persistence.js"; + +interface CaptureAutomaticProjectSnapshotInput { + projectId: string; + currentRevision: number; + createdAtIso: string; + actorId?: string; +} + +export function captureAutomaticProjectSnapshot( + database: AppDatabase, + input: CaptureAutomaticProjectSnapshotInput +): ProjectSnapshotMetadata | null { + const latest = database + .select({ sourceRevision: projectSnapshots.sourceRevision }) + .from(projectSnapshots) + .where( + and( + eq(projectSnapshots.projectId, input.projectId), + eq(projectSnapshots.kind, "automatic") + ) + ) + .orderBy(desc(projectSnapshots.sourceRevision)) + .get(); + if ( + !shouldCaptureAutomaticProjectSnapshot( + input.currentRevision, + latest?.sourceRevision ?? null + ) + ) { + return null; + } + + const current = readProjectStateSnapshot(database, input.projectId); + if (!current || current.currentRevision !== input.currentRevision) { + throw new Error( + "Automatic snapshot state does not match the appended revision." + ); + } + + const id = crypto.randomUUID(); + const preferredName = `Automatisch · Revision ${input.currentRevision}`; + const conflictingName = database + .select({ id: projectSnapshots.id }) + .from(projectSnapshots) + .where( + and( + eq(projectSnapshots.projectId, input.projectId), + eq(projectSnapshots.name, preferredName) + ) + ) + .get(); + const metadata: ProjectSnapshotMetadata = { + id, + projectId: input.projectId, + sourceRevision: input.currentRevision, + schemaVersion: projectStateSnapshotSchemaVersion, + kind: "automatic", + name: conflictingName + ? `${preferredName} · ${id}` + : preferredName, + description: `Automatischer Sicherungspunkt nach jeweils ${automaticProjectSnapshotIntervalRevisions} Projektänderungen.`, + payloadSha256: current.payloadSha256, + createdAtIso: input.createdAtIso, + createdByActorId: input.actorId ?? null, + }; + database + .insert(projectSnapshots) + .values({ + ...metadata, + payloadJson: current.payloadJson, + }) + .run(); + + const automaticSnapshots = database + .select({ id: projectSnapshots.id }) + .from(projectSnapshots) + .where( + and( + eq(projectSnapshots.projectId, input.projectId), + eq(projectSnapshots.kind, "automatic") + ) + ) + .orderBy( + desc(projectSnapshots.sourceRevision), + desc(projectSnapshots.createdAtIso), + desc(projectSnapshots.id) + ) + .all(); + const expiredIds = automaticSnapshots + .slice(automaticProjectSnapshotRetentionCount) + .map((snapshot) => snapshot.id); + if (expiredIds.length) { + database + .delete(projectSnapshots) + .where(inArray(projectSnapshots.id, expiredIds)) + .run(); + } + return metadata; +} diff --git a/src/db/repositories/project-revision.persistence.ts b/src/db/repositories/project-revision.persistence.ts index 05ee246..b05adb6 100644 --- a/src/db/repositories/project-revision.persistence.ts +++ b/src/db/repositories/project-revision.persistence.ts @@ -10,6 +10,7 @@ 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"; +import { captureAutomaticProjectSnapshot } from "./automatic-project-snapshot.persistence.js"; export function appendProjectRevision( database: AppDatabase, @@ -73,6 +74,13 @@ export function appendProjectRevision( }) .run(); + captureAutomaticProjectSnapshot(database, { + projectId: input.projectId, + currentRevision: revisionNumber, + createdAtIso, + actorId: input.actorId, + }); + return { revisionId, changeSetId, diff --git a/src/db/repositories/project-snapshot.repository.ts b/src/db/repositories/project-snapshot.repository.ts index 750aeec..5274fee 100644 --- a/src/db/repositories/project-snapshot.repository.ts +++ b/src/db/repositories/project-snapshot.repository.ts @@ -65,6 +65,7 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore { projectId: input.projectId, sourceRevision: current.currentRevision, schemaVersion: projectStateSnapshotSchemaVersion, + kind: "named", name: snapshotName, description: snapshotDescription, payloadSha256: current.payloadSha256, @@ -96,6 +97,7 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore { projectId: projectSnapshots.projectId, sourceRevision: projectSnapshots.sourceRevision, schemaVersion: projectSnapshots.schemaVersion, + kind: projectSnapshots.kind, name: projectSnapshots.name, description: projectSnapshots.description, payloadSha256: projectSnapshots.payloadSha256, @@ -146,6 +148,7 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore { projectId: stored.projectId, sourceRevision: stored.sourceRevision, schemaVersion: stored.schemaVersion, + kind: stored.kind, name: stored.name, description: stored.description, payloadSha256: stored.payloadSha256, diff --git a/src/db/schema/project-snapshots.ts b/src/db/schema/project-snapshots.ts index f01de9a..3684221 100644 --- a/src/db/schema/project-snapshots.ts +++ b/src/db/schema/project-snapshots.ts @@ -5,6 +5,7 @@ import { text, unique, } from "drizzle-orm/sqlite-core"; +import { projectSnapshotKinds } from "../../domain/models/project-snapshot-policy.model.js"; import { projects } from "./projects.js"; export const projectSnapshots = sqliteTable( @@ -16,6 +17,9 @@ export const projectSnapshots = sqliteTable( .references(() => projects.id, { onDelete: "cascade" }), sourceRevision: integer("source_revision").notNull(), schemaVersion: integer("schema_version").notNull(), + kind: text("kind", { enum: projectSnapshotKinds }) + .notNull() + .default("named"), name: text("name").notNull(), description: text("description"), payloadJson: text("payload_json").notNull(), @@ -32,5 +36,10 @@ export const projectSnapshots = sqliteTable( table.projectId, table.createdAtIso ), + index("project_snapshots_project_kind_revision_idx").on( + table.projectId, + table.kind, + table.sourceRevision + ), ] ); diff --git a/src/domain/models/project-snapshot-policy.model.ts b/src/domain/models/project-snapshot-policy.model.ts new file mode 100644 index 0000000..25e887e --- /dev/null +++ b/src/domain/models/project-snapshot-policy.model.ts @@ -0,0 +1,37 @@ +export const projectSnapshotKinds = ["named", "automatic"] as const; +export type ProjectSnapshotKind = + (typeof projectSnapshotKinds)[number]; + +export const automaticProjectSnapshotIntervalRevisions = 25; +export const automaticProjectSnapshotRetentionCount = 12; + +export function shouldCaptureAutomaticProjectSnapshot( + currentRevision: number, + latestAutomaticRevision: number | null +) { + assertRevision(currentRevision, "Current"); + if (latestAutomaticRevision !== null) { + assertRevision(latestAutomaticRevision, "Latest automatic"); + if (latestAutomaticRevision > currentRevision) { + throw new Error( + "Latest automatic snapshot revision exceeds current revision." + ); + } + } + if (currentRevision < automaticProjectSnapshotIntervalRevisions) { + return false; + } + return ( + latestAutomaticRevision === null || + currentRevision - latestAutomaticRevision >= + automaticProjectSnapshotIntervalRevisions + ); +} + +function assertRevision(value: number, label: string) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error( + `${label} snapshot revision must be a non-negative integer.` + ); + } +} diff --git a/src/domain/ports/project-snapshot.store.ts b/src/domain/ports/project-snapshot.store.ts index cdfabfd..327be1b 100644 --- a/src/domain/ports/project-snapshot.store.ts +++ b/src/domain/ports/project-snapshot.store.ts @@ -1,8 +1,12 @@ +import type { ProjectSnapshotKind } from "../models/project-snapshot-policy.model.js"; +import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js"; + export interface ProjectSnapshotMetadata { id: string; projectId: string; sourceRevision: number; schemaVersion: number; + kind: ProjectSnapshotKind; name: string; description: string | null; payloadSha256: string; @@ -33,4 +37,3 @@ export interface ProjectSnapshotStore { snapshotId: string ): PreparedProjectSnapshotRestore | null; } -import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js"; diff --git a/src/frontend/components/project-version-history.tsx b/src/frontend/components/project-version-history.tsx index 57fb806..826d9d5 100644 --- a/src/frontend/components/project-version-history.tsx +++ b/src/frontend/components/project-version-history.tsx @@ -24,6 +24,7 @@ import { import { getProjectRevisionDescription, getProjectRevisionSourceLabel, + getProjectSnapshotKindLabel, mergeProjectRevisionPages, } from "../utils/project-version-history"; @@ -352,6 +353,15 @@ export function ProjectVersionHistory({ {snapshot.name} + + {getProjectSnapshotKindLabel(snapshot.kind)} + {snapshot.description ? (
{snapshot.description} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 8724709..bb75085 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -46,6 +46,7 @@ export interface ProjectSnapshotMetadataDto { projectId: string; sourceRevision: number; schemaVersion: number; + kind: "named" | "automatic"; name: string; description: string | null; payloadSha256: string; diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index fe5976e..2493c7a 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -1,6 +1,7 @@ import type { ProjectRevisionSourceDto, ProjectRevisionSummaryDto, + ProjectSnapshotMetadataDto, } from "../types"; const sourceLabels: Record = { @@ -48,6 +49,12 @@ export function getProjectRevisionDescription( ); } +export function getProjectSnapshotKindLabel( + kind: ProjectSnapshotMetadataDto["kind"] +) { + return kind === "automatic" ? "Automatisch" : "Benannt"; +} + export function mergeProjectRevisionPages( current: ProjectRevisionSummaryDto[], next: ProjectRevisionSummaryDto[] diff --git a/tests/project-revision.repository.test.ts b/tests/project-revision.repository.test.ts index ffea437..8077a82 100644 --- a/tests/project-revision.repository.test.ts +++ b/tests/project-revision.repository.test.ts @@ -11,8 +11,10 @@ import { ProjectRevisionConflictError, ProjectRevisionRepository, } from "../src/db/repositories/project-revision.repository.js"; +import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projectSnapshots } from "../src/db/schema/project-snapshots.js"; import { projects } from "../src/db/schema/projects.js"; function createTestDatabase(): DatabaseContext { @@ -157,4 +159,117 @@ describe("project revision repository", () => { context.close(); } }); + + it("captures and retains automatic snapshots without deleting named snapshots", () => { + const context = createTestDatabase(); + try { + const revisions = new ProjectRevisionRepository(context.db); + const snapshots = new ProjectSnapshotRepository(context.db); + const named = snapshots.createNamed({ + projectId: "project-1", + expectedRevision: 0, + name: "Automatisch · Revision 25", + }); + assert.ok(named); + + for ( + let expectedRevision = 0; + expectedRevision < 325; + expectedRevision += 1 + ) { + appendTestRevision(revisions, expectedRevision); + } + + const stored = context.db + .select({ + id: projectSnapshots.id, + kind: projectSnapshots.kind, + name: projectSnapshots.name, + sourceRevision: projectSnapshots.sourceRevision, + createdByActorId: projectSnapshots.createdByActorId, + }) + .from(projectSnapshots) + .all(); + const automatic = stored + .filter((snapshot) => snapshot.kind === "automatic") + .sort( + (left, right) => + left.sourceRevision - right.sourceRevision + ); + assert.equal(automatic.length, 12); + assert.deepEqual( + automatic.map((snapshot) => snapshot.sourceRevision), + [50, 75, 100, 125, 150, 175, 200, 225, 250, 275, 300, 325] + ); + assert.equal( + automatic.every( + (snapshot) => snapshot.createdByActorId === "test-user" + ), + true + ); + assert.equal( + automatic.some( + (snapshot) => + snapshot.name === "Automatisch · Revision 25" + ), + false + ); + assert.deepEqual( + stored.filter((snapshot) => snapshot.kind === "named"), + [ + { + id: named.id, + kind: "named", + name: "Automatisch · Revision 25", + sourceRevision: 0, + createdByActorId: null, + }, + ] + ); + } finally { + context.close(); + } + }); + + it("rolls back revision 25 when automatic snapshot persistence fails", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectRevisionRepository(context.db); + for ( + let expectedRevision = 0; + expectedRevision < 24; + expectedRevision += 1 + ) { + appendTestRevision(repository, expectedRevision); + } + context.sqlite.exec(` + CREATE TRIGGER fail_automatic_project_snapshot + BEFORE INSERT ON project_snapshots + WHEN NEW.kind = 'automatic' + BEGIN + SELECT RAISE(ABORT, 'forced automatic snapshot failure'); + END; + `); + + assert.throws( + () => appendTestRevision(repository, 24), + /forced automatic snapshot failure/ + ); + assert.equal(repository.getCurrentRevision("project-1"), 24); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 24 + ); + assert.equal( + context.db.select().from(projectChangeSets).all().length, + 24 + ); + assert.equal( + context.db.select().from(projectSnapshots).all().length, + 0 + ); + } finally { + context.close(); + } + }); }); diff --git a/tests/project-snapshot-policy.test.ts b/tests/project-snapshot-policy.test.ts new file mode 100644 index 0000000..a6bb62f --- /dev/null +++ b/tests/project-snapshot-policy.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + automaticProjectSnapshotIntervalRevisions, + automaticProjectSnapshotRetentionCount, + shouldCaptureAutomaticProjectSnapshot, +} from "../src/domain/models/project-snapshot-policy.model.js"; + +describe("automatic project snapshot policy", () => { + it("captures every 25 revisions and retains twelve automatic snapshots", () => { + assert.equal(automaticProjectSnapshotIntervalRevisions, 25); + assert.equal(automaticProjectSnapshotRetentionCount, 12); + assert.equal( + shouldCaptureAutomaticProjectSnapshot(24, null), + false + ); + assert.equal( + shouldCaptureAutomaticProjectSnapshot(25, null), + true + ); + assert.equal( + shouldCaptureAutomaticProjectSnapshot(49, 25), + false + ); + assert.equal( + shouldCaptureAutomaticProjectSnapshot(50, 25), + true + ); + }); + + it("rejects invalid or inconsistent revision inputs", () => { + assert.throws( + () => shouldCaptureAutomaticProjectSnapshot(-1, null), + /non-negative integer/ + ); + assert.throws( + () => shouldCaptureAutomaticProjectSnapshot(25, 26), + /exceeds current revision/ + ); + }); +}); diff --git a/tests/project-version-history.test.ts b/tests/project-version-history.test.ts index 6ef96be..68c6d99 100644 --- a/tests/project-version-history.test.ts +++ b/tests/project-version-history.test.ts @@ -16,6 +16,7 @@ import { import { getProjectRevisionDescription, getProjectRevisionSourceLabel, + getProjectSnapshotKindLabel, mergeProjectRevisionPages, } from "../src/frontend/utils/project-version-history.js"; @@ -79,6 +80,11 @@ describe("project version history presentation", () => { ), "future.command" ); + assert.equal(getProjectSnapshotKindLabel("named"), "Benannt"); + assert.equal( + getProjectSnapshotKindLabel("automatic"), + "Automatisch" + ); }); it("merges paginated revisions without duplicates in descending order", () => {