Assessor_2.0/src/compliance/local-compliance-store.ts

155 lines
8.6 KiB
TypeScript

import { RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../persistence/repository-errors';
import type { ComplianceProjectExport, ComplianceStore } from './compliance-store';
import {
regulatoryMappingBatchRunValidationErrors,
regulatoryMappingDecisionValidationErrors,
regulatoryMappingRunValidationErrors,
type RegulatoryMappingBatchRun,
type RegulatoryMappingDecision,
type RegulatoryMappingRun
} from './regulatory-mapping';
import { aiOverviewRunValidationErrors, assertAiOverviewTransition, type AiOverviewRun } from './ai-overview';
function clone<T>(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[] = [];
async getRegulatoryMappingRun(id: string): Promise<RegulatoryMappingRun | null> {
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<RegulatoryMappingRun> {
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<RegulatoryMappingRun[]> {
return this.runs.map(clone).sort(newestFirst);
}
async createRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun> {
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<RegulatoryMappingBatchRun> {
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<RegulatoryMappingBatchRun[]> {
return this.batches.filter(item => item.projectId === projectId && (!scopeKey || item.scopeKey === scopeKey))
.map(clone).reverse();
}
async createRegulatoryMappingDecision(decision: RegulatoryMappingDecision, expectedLatestDecisionId?: string): Promise<RegulatoryMappingDecision> {
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') {
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).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): Promise<RegulatoryMappingDecision[]> {
return this.decisions.filter(item => item.ruleKey === ruleKey).map(clone).sort(newestFirst);
}
async createAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun> {
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<AiOverviewRun> {
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<AiOverviewRun[]> {
return this.aiOverviewRuns.filter(item => item.projectId === projectId && (!scopeKey || item.scopeKey === scopeKey))
.map(clone).sort(newestFirst);
}
async deleteAiOverviewRuns(projectId: string, runIds: string[]): Promise<void> {
const ids = new Set(runIds);
this.aiOverviewRuns = this.aiOverviewRuns.filter(item => item.projectId !== projectId || !ids.has(item.id));
}
async exportProject(projectId: string): Promise<ComplianceProjectExport> {
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),
aiOverviewRuns: this.aiOverviewRuns.filter(item => item.projectId === projectId && item.status === 'succeeded')
.map(clone).sort(newestFirst)
};
}
}