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
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user