Add automatic project snapshots
This commit is contained in:
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user