import { RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../persistence/repository-errors'; import type { ComplianceProjectExport, ComplianceStore } from './compliance-store'; import { regulatoryMappingBatchRunValidationErrors, regulatoryMappingDecisionValidationErrors, regulatoryMappingRunValidationErrors, mappingFramework, type RegulatoryFramework, type RegulatoryMappingBatchRun, type RegulatoryMappingDecision, type RegulatoryMappingRun } from './regulatory-mapping'; import { aiOverviewRunValidationErrors, assertAiOverviewTransition, type AiOverviewRun } from './ai-overview'; import { regulatoryConsistencyReviewValidationErrors, type RegulatoryConsistencyReview } from './bsi-consistency'; function clone(value: T): T { return structuredClone(value); } const newestFirst = (left: { createdAt: string; id: string }, right: { createdAt: string; id: string }) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id); /** In-memory engine storage for the memory persistence mode and tests. */ export class LocalComplianceStore implements ComplianceStore { private runs: RegulatoryMappingRun[] = []; private batches: RegulatoryMappingBatchRun[] = []; private decisions: RegulatoryMappingDecision[] = []; private aiOverviewRuns: AiOverviewRun[] = []; private consistencyReviews: RegulatoryConsistencyReview[] = []; async getRegulatoryMappingRun(id: string): Promise { const run = this.runs.find(item => item.id === id); return run ? clone(run) : null; } async claimRegulatoryMappingRun(run: RegulatoryMappingRun, forceRegeneration: boolean): Promise<{ run: RegulatoryMappingRun; claimed: boolean }> { const errors = regulatoryMappingRunValidationErrors(run); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const existing = this.runs.find(item => item.id === run.id); if (existing && (['queued', 'running'].includes(existing.status) || existing.status === 'succeeded' && !forceRegeneration)) { return { run: clone(existing), claimed: false }; } const stored = await this.saveRegulatoryMappingRun(run); return { run: stored, claimed: true }; } async saveRegulatoryMappingRun(run: RegulatoryMappingRun): Promise { const errors = regulatoryMappingRunValidationErrors(run); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const index = this.runs.findIndex(item => item.id === run.id); if (index >= 0) this.runs[index] = clone(run); else this.runs.push(clone(run)); return clone(run); } async listRegulatoryMappingRuns(): Promise { return this.runs.map(clone).sort(newestFirst); } async createRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise { const errors = regulatoryMappingBatchRunValidationErrors(run); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const idempotent = this.batches.find(item => item.projectId === run.projectId && item.idempotencyKey === run.idempotencyKey); if (idempotent) return clone(idempotent); const active = this.batches.find(item => item.projectId === run.projectId && item.scopeKey === run.scopeKey && ['queued', 'running'].includes(item.status)); if (active) return clone(active); this.batches.push(clone(run)); return clone(run); } async updateRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise { const errors = regulatoryMappingBatchRunValidationErrors(run); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const index = this.batches.findIndex(item => item.id === run.id); const existing = this.batches[index]; if (!existing) throw new RepositoryNotFoundError('Regulatory mapping batch does not exist.'); if (existing.projectId !== run.projectId || existing.scopeKey !== run.scopeKey || existing.idempotencyKey !== run.idempotencyKey) { throw new RepositoryConflictError('Regulatory mapping batch identity is immutable.'); } if (['completed', 'failed'].includes(existing.status)) throw new RepositoryConflictError('A completed regulatory mapping batch is immutable.'); const allowed = existing.status === 'queued' ? ['running', 'failed'] : ['running', 'completed', 'failed']; if (!allowed.includes(run.status)) throw new RepositoryConflictError(`Invalid regulatory mapping batch transition from ${existing.status} to ${run.status}.`); this.batches[index] = clone(run); return clone(run); } async listRegulatoryMappingBatchRuns(projectId: string, scopeKey?: string): Promise { return this.batches.filter(item => item.projectId === projectId && (!scopeKey || item.scopeKey === scopeKey)) .map(clone).reverse(); } async createRegulatoryMappingDecision(decision: RegulatoryMappingDecision, expectedLatestDecisionId?: string): Promise { const errors = regulatoryMappingDecisionValidationErrors(decision); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const run = this.runs.find(item => item.id === decision.mappingId); if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded' || mappingFramework(run) !== mappingFramework(decision)) { throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.'); } const latest = this.decisions.filter(item => item.ruleKey === decision.ruleKey && item.controlId === decision.controlId && mappingFramework(item) === mappingFramework(decision)).sort(newestFirst)[0]; // A control with an earlier decision stays decidable after a remap no longer retrieves it; the chain of // previous decisions leads back to the run that grounded it. if (!latest && !run.candidates.some(candidate => candidate.controlId === decision.controlId)) throw new RepositoryValidationError('Decision control was not a grounded candidate.'); if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) throw new RepositoryConflictError('Regulatory mapping decision changed before this update.'); const stored = clone({ ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) }); this.decisions.push(stored); return clone(stored); } async listRegulatoryMappingDecisions(ruleKey: string, framework: RegulatoryFramework = 'iso27001'): Promise { return this.decisions.filter(item => item.ruleKey === ruleKey && mappingFramework(item) === framework).map(clone).sort(newestFirst); } async createRegulatoryConsistencyReview(review: RegulatoryConsistencyReview): Promise { const errors = regulatoryConsistencyReviewValidationErrors(review); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); this.consistencyReviews.push(clone(review)); return clone(review); } async listRegulatoryConsistencyReviews(): Promise { return this.consistencyReviews.map(clone).sort(newestFirst); } async createAiOverviewRun(run: AiOverviewRun): Promise { const errors = aiOverviewRunValidationErrors(run); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const idempotent = this.aiOverviewRuns.find(item => item.projectId === run.projectId && item.scopeKey === run.scopeKey && item.idempotencyKey === run.idempotencyKey); if (idempotent) return clone(idempotent); if (this.aiOverviewRuns.some(item => item.projectId === run.projectId && item.scopeKey === run.scopeKey && ['queued', 'running'].includes(item.status))) { throw new RepositoryConflictError('For this timeslot, an AI overview is already being generated.'); } if (this.aiOverviewRuns.some(item => item.id === run.id)) throw new RepositoryConflictError(`AI overview run ${run.id} already exists.`); this.aiOverviewRuns.push(clone(run)); return clone(run); } async updateAiOverviewRun(run: AiOverviewRun): Promise { const errors = aiOverviewRunValidationErrors(run); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const index = this.aiOverviewRuns.findIndex(item => item.id === run.id); if (index < 0) throw new RepositoryNotFoundError(`AI overview run ${run.id} does not exist.`); assertAiOverviewTransition(this.aiOverviewRuns[index], run); this.aiOverviewRuns[index] = clone(run); return clone(run); } async listAiOverviewRuns(projectId: string, scopeKey?: string): Promise { return this.aiOverviewRuns.filter(item => item.projectId === projectId && (!scopeKey || item.scopeKey === scopeKey)) .map(clone).sort(newestFirst); } async deleteAiOverviewRuns(projectId: string, runIds: string[]): Promise { const ids = new Set(runIds); this.aiOverviewRuns = this.aiOverviewRuns.filter(item => item.projectId !== projectId || !ids.has(item.id)); } async exportProject(projectId: string): Promise { return { regulatoryMappingBatchRuns: this.batches.filter(item => item.projectId === projectId).map(clone), regulatoryMappingRuns: this.runs.filter(item => item.status === 'succeeded').map(clone), regulatoryMappingDecisions: this.decisions.map(clone), regulatoryConsistencyReviews: this.consistencyReviews.map(clone), aiOverviewRuns: this.aiOverviewRuns.filter(item => item.projectId === projectId && item.status === 'succeeded') .map(clone).sort(newestFirst) }; } }