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." ); } }