204 lines
6.4 KiB
TypeScript
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."
|
|
);
|
|
}
|
|
}
|