155 lines
8.6 KiB
TypeScript
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)
|
|
};
|
|
}
|
|
}
|