Add automatic project snapshots
This commit is contained in:
@@ -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
|
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||||
project data atomically and records a new `restore` revision with a complete
|
project data atomically and records a new `restore` revision with a complete
|
||||||
inverse command. Restore can therefore be undone and redone after a restart.
|
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
|
The project page exposes persistent project-wide Undo/Redo in the header of an
|
||||||
initially collapsed German snapshot/timeline UI, with explicit restore
|
initially collapsed German snapshot/timeline UI, with explicit restore
|
||||||
confirmation and cursor-based loading of older revision metadata.
|
confirmation and cursor-based loading of older revision metadata.
|
||||||
@@ -314,7 +317,6 @@ Users must be able to override sizing suggestions.
|
|||||||
|
|
||||||
## Current Deferred Work
|
## Current Deferred Work
|
||||||
|
|
||||||
- periodic snapshot and retention policy
|
|
||||||
- Revit/CSV/IFCGUID round-trip
|
- Revit/CSV/IFCGUID round-trip
|
||||||
- full electrical sizing
|
- full electrical sizing
|
||||||
- multi-user/PostgreSQL operation
|
- multi-user/PostgreSQL operation
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
|||||||
|
|
||||||
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
|
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
|
||||||
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
||||||
automatische periodische Sicherungspunkte und der Revit-/IFCGUID-Datenaustausch
|
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
|
||||||
sind noch nicht implementiert.
|
implementiert.
|
||||||
|
|
||||||
## Unterstützter Arbeitsablauf
|
## Unterstützter Arbeitsablauf
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ sind noch nicht implementiert.
|
|||||||
- BMKs stabil halten und nur auf ausdrücklichen Befehl neu nummerieren
|
- BMKs stabil halten und nur auf ausdrücklichen Befehl neu nummerieren
|
||||||
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
|
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
|
||||||
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
|
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
|
||||||
|
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
|
||||||
|
|
||||||
## Technik
|
## Technik
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ this value for optimistic concurrency checks.
|
|||||||
- executes the eligible inverse or forward command as a new auditable
|
- executes the eligible inverse or forward command as a new auditable
|
||||||
revision
|
revision
|
||||||
- `GET /projects/:projectId/snapshots`
|
- `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`
|
- `POST /projects/:projectId/snapshots`
|
||||||
- body:
|
- body:
|
||||||
`{ "expectedRevision": 12, "name": "Vor Ausschreibung", "description": "Optional" }`
|
`{ "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
|
- snapshot creation does not increment the project revision or alter
|
||||||
undo/redo stacks
|
undo/redo stacks
|
||||||
- duplicate names and stale revisions return `409`
|
- 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`
|
- `POST /projects/:projectId/snapshots/:snapshotId/restore`
|
||||||
- body: `{ "expectedRevision": 13 }`
|
- body: `{ "expectedRevision": 13 }`
|
||||||
- verifies the stored payload checksum and atomically restores the complete
|
- verifies the stored payload checksum and atomically restores the complete
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
eligibility is restored from server history after a page reload.
|
eligibility is restored from server history after a page reload.
|
||||||
- The project page exposes the paginated revision timeline; the circuit editor
|
- The project page exposes the paginated revision timeline; the circuit editor
|
||||||
itself currently exposes only the next eligible Undo/Redo actions.
|
itself currently exposes only the next eligible Undo/Redo actions.
|
||||||
- Named logical project snapshots can be created, listed and restored through
|
- Named and automatic logical project snapshots can be listed and restored
|
||||||
the project page with persistent Undo/Redo. Automatic periodic snapshots are
|
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||||
not implemented yet.
|
created every 25 revisions and only their newest 12 entries are retained.
|
||||||
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
|
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
|
||||||
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
- 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
|
- The global device library supports basic CRUD and copy operations, but has no
|
||||||
|
|||||||
@@ -69,24 +69,24 @@ besitzt einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change
|
|||||||
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
|
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
|
||||||
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
|
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
|
||||||
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
|
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
|
||||||
einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und
|
einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten
|
||||||
bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze
|
Circuit-, Gerätezeilen-, Projektgeräte- und Projekteinstellungsänderungen
|
||||||
angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren interne
|
verwenden typisierte Command-Stores, die Fachänderung, automatisch erzeugtes
|
||||||
Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und
|
inverses Kommando, Revision und Historienstapel gemeinsam committen
|
||||||
Revision gemeinsam committen beziehungsweise zurückrollen können.
|
beziehungsweise zurückrollen.
|
||||||
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
|
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
|
||||||
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
|
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
|
||||||
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
|
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
|
||||||
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
|
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
|
||||||
`GET /api/projects/:projectId/history` lesbar. Die unveränderliche,
|
`GET /api/projects/:projectId/history` lesbar. Die unveränderliche,
|
||||||
absteigend paginierte Revisions-Timeline ist ohne Befehls-Payloads über
|
absteigend paginierte Revisions-Timeline ist ohne Befehls-Payloads über
|
||||||
`GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler Dispatcher führt
|
`GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler
|
||||||
die unterstützten Typen `circuit.update` und `circuit-device-row.update` über
|
Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über
|
||||||
öffentliche Command-, Undo- und Redo-Endpunkte aus. Zusätzlich sind
|
öffentliche Command-, Undo- und Redo-Endpunkte aus.
|
||||||
`circuit-device-row.insert` und `circuit-device-row.delete` als atomare
|
`circuit-device-row.insert` und `circuit-device-row.delete` sind atomare
|
||||||
Strukturkommandos vorhanden. Beim Löschen wird die vollständige Zeile im
|
Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen
|
||||||
inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte
|
Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte wiederherstellt.
|
||||||
wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen
|
`circuit.insert` und `circuit.delete` behandeln einen
|
||||||
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
|
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
|
||||||
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
|
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
|
||||||
Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen
|
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,
|
Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte,
|
||||||
Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots.
|
Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots.
|
||||||
Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel.
|
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,
|
`POST /api/projects/:projectId/snapshots/:snapshotId/restore` prüft Payload,
|
||||||
Prüfsumme, erwartete Revision und den unmittelbar zuvor gelesenen
|
Prüfsumme, erwartete Revision und den unmittelbar zuvor gelesenen
|
||||||
Projektzustand. Der Restore ersetzt alle unterstützten Projektdaten in einer
|
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
|
## Noch nicht unterstützt
|
||||||
|
|
||||||
- automatische periodische Projekt-Snapshots und Aufbewahrungsrichtlinie
|
|
||||||
- Mehrbenutzerbetrieb und Konfliktauflösung
|
- Mehrbenutzerbetrieb und Konfliktauflösung
|
||||||
- Revit-/CSV-/IFCGUID-Round-trip
|
- Revit-/CSV-/IFCGUID-Round-trip
|
||||||
- vollständige elektrische Dimensionierung
|
- vollständige elektrische Dimensionierung
|
||||||
|
|||||||
@@ -101,6 +101,17 @@ Implemented named-snapshot foundation:
|
|||||||
explicit inline restore confirmation and cursor-based loading of older
|
explicit inline restore confirmation and cursor-based loading of older
|
||||||
revisions; a successful restore reloads all project read models
|
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
|
## Revit / CSV / IFCGUID Round-Trip Direction
|
||||||
|
|
||||||
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
|
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:
|
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
|
- extend project-scoped commands only when further project mutations are
|
||||||
deliberately added to history; the generic versioned envelope is complete
|
deliberately added to history; the generic versioned envelope is complete
|
||||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||||
@@ -266,7 +276,6 @@ Remaining constraints before completing project version history:
|
|||||||
|
|
||||||
## Deferred Decisions
|
## Deferred Decisions
|
||||||
|
|
||||||
- exact snapshot frequency and retention policy
|
|
||||||
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
||||||
- branch visualization after undo followed by new edits
|
- branch visualization after undo followed by new edits
|
||||||
- user/role model and actor attribution
|
- user/role model and actor attribution
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ Tasks:
|
|||||||
command payloads
|
command payloads
|
||||||
- [x] add explicitly named logical project snapshots with schema-versioned
|
- [x] add explicitly named logical project snapshots with schema-versioned
|
||||||
payloads and integrity checksums
|
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
|
- [x] restore a historical snapshot as a new auditable revision with
|
||||||
persistent undo/redo
|
persistent undo/redo
|
||||||
- [x] expose named snapshots, confirmed restore and the paginated revision
|
- [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
|
- named snapshot Create/List endpoints persist complete project-scoped runtime
|
||||||
state with the source revision and SHA-256 without changing revision or
|
state with the source revision and SHA-256 without changing revision or
|
||||||
undo/redo stacks
|
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
|
- the project page lists, creates and explicitly restores named snapshots and
|
||||||
displays paginated revision metadata without exposing command payloads
|
displays paginated revision metadata without exposing command payloads
|
||||||
- the version-card header reads persisted undo/redo eligibility and executes
|
- the version-card header reads persisted undo/redo eligibility and executes
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"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": "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.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:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "tsx scripts/db-backup.ts",
|
"db:backup": "tsx scripts/db-backup.ts",
|
||||||
|
|||||||
@@ -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`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
|||||||
"when": 1785009799979,
|
"when": 1785009799979,
|
||||||
"tag": "0014_project_snapshots",
|
"tag": "0014_project_snapshots",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785014383032,
|
||||||
|
"tag": "0015_modern_madame_masque",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import type { AppDatabase } from "../database-context.js";
|
|||||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||||
import { projectRevisions } from "../schema/project-revisions.js";
|
import { projectRevisions } from "../schema/project-revisions.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { captureAutomaticProjectSnapshot } from "./automatic-project-snapshot.persistence.js";
|
||||||
|
|
||||||
export function appendProjectRevision(
|
export function appendProjectRevision(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
@@ -73,6 +74,13 @@ export function appendProjectRevision(
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
captureAutomaticProjectSnapshot(database, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
currentRevision: revisionNumber,
|
||||||
|
createdAtIso,
|
||||||
|
actorId: input.actorId,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
revisionId,
|
revisionId,
|
||||||
changeSetId,
|
changeSetId,
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
|||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
sourceRevision: current.currentRevision,
|
sourceRevision: current.currentRevision,
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
|
kind: "named",
|
||||||
name: snapshotName,
|
name: snapshotName,
|
||||||
description: snapshotDescription,
|
description: snapshotDescription,
|
||||||
payloadSha256: current.payloadSha256,
|
payloadSha256: current.payloadSha256,
|
||||||
@@ -96,6 +97,7 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
|||||||
projectId: projectSnapshots.projectId,
|
projectId: projectSnapshots.projectId,
|
||||||
sourceRevision: projectSnapshots.sourceRevision,
|
sourceRevision: projectSnapshots.sourceRevision,
|
||||||
schemaVersion: projectSnapshots.schemaVersion,
|
schemaVersion: projectSnapshots.schemaVersion,
|
||||||
|
kind: projectSnapshots.kind,
|
||||||
name: projectSnapshots.name,
|
name: projectSnapshots.name,
|
||||||
description: projectSnapshots.description,
|
description: projectSnapshots.description,
|
||||||
payloadSha256: projectSnapshots.payloadSha256,
|
payloadSha256: projectSnapshots.payloadSha256,
|
||||||
@@ -146,6 +148,7 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
|||||||
projectId: stored.projectId,
|
projectId: stored.projectId,
|
||||||
sourceRevision: stored.sourceRevision,
|
sourceRevision: stored.sourceRevision,
|
||||||
schemaVersion: stored.schemaVersion,
|
schemaVersion: stored.schemaVersion,
|
||||||
|
kind: stored.kind,
|
||||||
name: stored.name,
|
name: stored.name,
|
||||||
description: stored.description,
|
description: stored.description,
|
||||||
payloadSha256: stored.payloadSha256,
|
payloadSha256: stored.payloadSha256,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
text,
|
text,
|
||||||
unique,
|
unique,
|
||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
import { projectSnapshotKinds } from "../../domain/models/project-snapshot-policy.model.js";
|
||||||
import { projects } from "./projects.js";
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
export const projectSnapshots = sqliteTable(
|
export const projectSnapshots = sqliteTable(
|
||||||
@@ -16,6 +17,9 @@ export const projectSnapshots = sqliteTable(
|
|||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
sourceRevision: integer("source_revision").notNull(),
|
sourceRevision: integer("source_revision").notNull(),
|
||||||
schemaVersion: integer("schema_version").notNull(),
|
schemaVersion: integer("schema_version").notNull(),
|
||||||
|
kind: text("kind", { enum: projectSnapshotKinds })
|
||||||
|
.notNull()
|
||||||
|
.default("named"),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
payloadJson: text("payload_json").notNull(),
|
payloadJson: text("payload_json").notNull(),
|
||||||
@@ -32,5 +36,10 @@ export const projectSnapshots = sqliteTable(
|
|||||||
table.projectId,
|
table.projectId,
|
||||||
table.createdAtIso
|
table.createdAtIso
|
||||||
),
|
),
|
||||||
|
index("project_snapshots_project_kind_revision_idx").on(
|
||||||
|
table.projectId,
|
||||||
|
table.kind,
|
||||||
|
table.sourceRevision
|
||||||
|
),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
export interface ProjectSnapshotMetadata {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
sourceRevision: number;
|
sourceRevision: number;
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
|
kind: ProjectSnapshotKind;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
payloadSha256: string;
|
payloadSha256: string;
|
||||||
@@ -33,4 +37,3 @@ export interface ProjectSnapshotStore {
|
|||||||
snapshotId: string
|
snapshotId: string
|
||||||
): PreparedProjectSnapshotRestore | null;
|
): PreparedProjectSnapshotRestore | null;
|
||||||
}
|
}
|
||||||
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getProjectRevisionDescription,
|
getProjectRevisionDescription,
|
||||||
getProjectRevisionSourceLabel,
|
getProjectRevisionSourceLabel,
|
||||||
|
getProjectSnapshotKindLabel,
|
||||||
mergeProjectRevisionPages,
|
mergeProjectRevisionPages,
|
||||||
} from "../utils/project-version-history";
|
} from "../utils/project-version-history";
|
||||||
|
|
||||||
@@ -352,6 +353,15 @@ export function ProjectVersionHistory({
|
|||||||
<tr key={snapshot.id}>
|
<tr key={snapshot.id}>
|
||||||
<td>
|
<td>
|
||||||
<strong>{snapshot.name}</strong>
|
<strong>{snapshot.name}</strong>
|
||||||
|
<span
|
||||||
|
className={`badge ms-2 ${
|
||||||
|
snapshot.kind === "automatic"
|
||||||
|
? "text-bg-info"
|
||||||
|
: "text-bg-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getProjectSnapshotKindLabel(snapshot.kind)}
|
||||||
|
</span>
|
||||||
{snapshot.description ? (
|
{snapshot.description ? (
|
||||||
<div className="small text-secondary">
|
<div className="small text-secondary">
|
||||||
{snapshot.description}
|
{snapshot.description}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface ProjectSnapshotMetadataDto {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
sourceRevision: number;
|
sourceRevision: number;
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
|
kind: "named" | "automatic";
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
payloadSha256: string;
|
payloadSha256: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ProjectRevisionSourceDto,
|
ProjectRevisionSourceDto,
|
||||||
ProjectRevisionSummaryDto,
|
ProjectRevisionSummaryDto,
|
||||||
|
ProjectSnapshotMetadataDto,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
const sourceLabels: Record<ProjectRevisionSourceDto, string> = {
|
const sourceLabels: Record<ProjectRevisionSourceDto, string> = {
|
||||||
@@ -48,6 +49,12 @@ export function getProjectRevisionDescription(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProjectSnapshotKindLabel(
|
||||||
|
kind: ProjectSnapshotMetadataDto["kind"]
|
||||||
|
) {
|
||||||
|
return kind === "automatic" ? "Automatisch" : "Benannt";
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeProjectRevisionPages(
|
export function mergeProjectRevisionPages(
|
||||||
current: ProjectRevisionSummaryDto[],
|
current: ProjectRevisionSummaryDto[],
|
||||||
next: ProjectRevisionSummaryDto[]
|
next: ProjectRevisionSummaryDto[]
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
ProjectRevisionConflictError,
|
ProjectRevisionConflictError,
|
||||||
ProjectRevisionRepository,
|
ProjectRevisionRepository,
|
||||||
} from "../src/db/repositories/project-revision.repository.js";
|
} 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 { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.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";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
|
||||||
function createTestDatabase(): DatabaseContext {
|
function createTestDatabase(): DatabaseContext {
|
||||||
@@ -157,4 +159,117 @@ describe("project revision repository", () => {
|
|||||||
context.close();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getProjectRevisionDescription,
|
getProjectRevisionDescription,
|
||||||
getProjectRevisionSourceLabel,
|
getProjectRevisionSourceLabel,
|
||||||
|
getProjectSnapshotKindLabel,
|
||||||
mergeProjectRevisionPages,
|
mergeProjectRevisionPages,
|
||||||
} from "../src/frontend/utils/project-version-history.js";
|
} from "../src/frontend/utils/project-version-history.js";
|
||||||
|
|
||||||
@@ -79,6 +80,11 @@ describe("project version history presentation", () => {
|
|||||||
),
|
),
|
||||||
"future.command"
|
"future.command"
|
||||||
);
|
);
|
||||||
|
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
|
||||||
|
assert.equal(
|
||||||
|
getProjectSnapshotKindLabel("automatic"),
|
||||||
|
"Automatisch"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("merges paginated revisions without duplicates in descending order", () => {
|
it("merges paginated revisions without duplicates in descending order", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user