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
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
+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
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
+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
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
+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
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
+4 -1
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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,
+9
View File
@@ -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.`
);
}
}
+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 {
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}
+1
View File
@@ -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[]
+115
View File
@@ -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();
}
});
});
+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 {
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", () => {