Add automatic project snapshots

This commit is contained in:
2026-07-25 23:27:51 +02:00
parent 59d442593f
commit 1a77eedaa8
23 changed files with 2232 additions and 26 deletions
+3 -1
View File
@@ -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
+3 -2
View File
@@ -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
+5 -1
View File
@@ -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
+18 -13
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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,
+9
View File
@@ -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.`
);
}
}
+4 -1
View File
@@ -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}
+1
View File
@@ -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[]
+115
View File
@@ -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();
}
});
}); });
+41
View File
@@ -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/
);
});
});
+6
View File
@@ -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", () => {