Files
leistungsbilanz-ts/src/db/repositories/project-snapshot.repository.ts
T

204 lines
6.4 KiB
TypeScript

import crypto from "node:crypto";
import { and, desc, eq } from "drizzle-orm";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import {
distributionBoardProjectStateSnapshotSchemaVersion,
deserializeProjectStateSnapshot,
legacyProjectStateSnapshotSchemaVersion,
previousProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion,
supplyTypesProjectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
CreateNamedProjectSnapshotInput,
PreparedProjectSnapshotRestore,
ProjectSnapshotMetadata,
ProjectSnapshotStore,
} from "../../domain/ports/project-snapshot.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectSnapshots } from "../schema/project-snapshots.js";
import { projects } from "../schema/projects.js";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
} from "./project-state-snapshot.persistence.js";
export class ProjectSnapshotRepository implements ProjectSnapshotStore {
constructor(private readonly database: AppDatabase) {}
createNamed(
input: CreateNamedProjectSnapshotInput
): ProjectSnapshotMetadata | null {
validateCreateInput(input);
const snapshotName = input.name.trim();
const snapshotDescription =
input.description?.trim() || null;
return this.database.transaction((tx) => {
const current = readProjectStateSnapshot(tx, input.projectId);
if (!current) {
return null;
}
if (current.currentRevision !== input.expectedRevision) {
throw new ProjectRevisionConflictError(
input.projectId,
input.expectedRevision,
current.currentRevision
);
}
const existing = tx
.select({ id: projectSnapshots.id })
.from(projectSnapshots)
.where(
and(
eq(projectSnapshots.projectId, input.projectId),
eq(projectSnapshots.name, snapshotName)
)
)
.get();
if (existing) {
throw new ProjectSnapshotNameConflictError(
input.projectId,
snapshotName
);
}
const metadata: ProjectSnapshotMetadata = {
id: crypto.randomUUID(),
projectId: input.projectId,
sourceRevision: current.currentRevision,
schemaVersion: projectStateSnapshotSchemaVersion,
kind: "named",
name: snapshotName,
description: snapshotDescription,
payloadSha256: current.payloadSha256,
createdAtIso: new Date().toISOString(),
createdByActorId: input.actorId ?? null,
};
tx.insert(projectSnapshots)
.values({
...metadata,
payloadJson: current.payloadJson,
})
.run();
return metadata;
});
}
listByProject(projectId: string): ProjectSnapshotMetadata[] | null {
const project = this.database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
return null;
}
return this.database
.select({
id: projectSnapshots.id,
projectId: projectSnapshots.projectId,
sourceRevision: projectSnapshots.sourceRevision,
schemaVersion: projectSnapshots.schemaVersion,
kind: projectSnapshots.kind,
name: projectSnapshots.name,
description: projectSnapshots.description,
payloadSha256: projectSnapshots.payloadSha256,
createdAtIso: projectSnapshots.createdAtIso,
createdByActorId: projectSnapshots.createdByActorId,
})
.from(projectSnapshots)
.where(eq(projectSnapshots.projectId, projectId))
.orderBy(
desc(projectSnapshots.createdAtIso),
desc(projectSnapshots.id)
)
.all();
}
prepareRestore(
projectId: string,
snapshotId: string
): PreparedProjectSnapshotRestore | null {
const current = readProjectStateSnapshot(this.database, projectId);
if (!current) {
return null;
}
const stored = this.database
.select()
.from(projectSnapshots)
.where(
and(
eq(projectSnapshots.id, snapshotId),
eq(projectSnapshots.projectId, projectId)
)
)
.get();
if (!stored) {
return null;
}
const actualChecksum = hashProjectStatePayload(stored.payloadJson);
if (actualChecksum !== stored.payloadSha256) {
throw new Error("Project snapshot checksum verification failed.");
}
if (
stored.schemaVersion !== legacyProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== previousProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
distributionBoardProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
supplyTypesProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== projectStateSnapshotSchemaVersion
) {
throw new Error("Project snapshot schema version is not supported.");
}
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);
return {
snapshot: {
id: stored.id,
projectId: stored.projectId,
sourceRevision: stored.sourceRevision,
schemaVersion: stored.schemaVersion,
kind: stored.kind,
name: stored.name,
description: stored.description,
payloadSha256: stored.payloadSha256,
createdAtIso: stored.createdAtIso,
createdByActorId: stored.createdByActorId,
},
command: createProjectStateRestoreCommand(
current.payloadSha256,
targetState
),
};
}
}
function validateCreateInput(input: CreateNamedProjectSnapshotInput) {
if (
!Number.isSafeInteger(input.expectedRevision) ||
input.expectedRevision < 0
) {
throw new Error(
"Expected project revision must be a non-negative integer."
);
}
if (!input.name.trim()) {
throw new Error("Project snapshot name is required.");
}
if (input.name.trim().length > 100) {
throw new Error(
"Project snapshot name must not exceed 100 characters."
);
}
if (
input.description !== undefined &&
input.description.trim().length > 500
) {
throw new Error(
"Project snapshot description must not exceed 500 characters."
);
}
}