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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
"tag": "0014_project_snapshots",
|
||||
"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 { 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
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";
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
getProjectRevisionDescription,
|
||||
getProjectRevisionSourceLabel,
|
||||
getProjectSnapshotKindLabel,
|
||||
mergeProjectRevisionPages,
|
||||
} from "../utils/project-version-history";
|
||||
|
||||
@@ -352,6 +353,15 @@ export function ProjectVersionHistory({
|
||||
<tr key={snapshot.id}>
|
||||
<td>
|
||||
<strong>{snapshot.name}</strong>
|
||||
<span
|
||||
className={`badge ms-2 ${
|
||||
snapshot.kind === "automatic"
|
||||
? "text-bg-info"
|
||||
: "text-bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{getProjectSnapshotKindLabel(snapshot.kind)}
|
||||
</span>
|
||||
{snapshot.description ? (
|
||||
<div className="small text-secondary">
|
||||
{snapshot.description}
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface ProjectSnapshotMetadataDto {
|
||||
projectId: string;
|
||||
sourceRevision: number;
|
||||
schemaVersion: number;
|
||||
kind: "named" | "automatic";
|
||||
name: string;
|
||||
description: string | null;
|
||||
payloadSha256: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ProjectRevisionSourceDto,
|
||||
ProjectRevisionSummaryDto,
|
||||
ProjectSnapshotMetadataDto,
|
||||
} from "../types";
|
||||
|
||||
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(
|
||||
current: ProjectRevisionSummaryDto[],
|
||||
next: ProjectRevisionSummaryDto[]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user