/**
* @fileoverview QualityMetrics aggregate root
*
* This module defines the QualityMetrics aggregate which represents quality metrics
* for a project with their thresholds, measurements, and history.
*/
import { AggregateRoot } from '../../shared/aggregate-root.js';
import { ProjectKey, GraphQLNodeId } from '../../../types/branded.js';
import { ThresholdValue } from '../../value-objects/threshold-value.js';
import { MetricValue } from '../../value-objects/metric-value.js';
import {
QualityMetricsId,
MetricConfiguration,
MetricHistoryEntry,
MetricTrend,
ThresholdStatus,
CreateQualityMetricsParams,
UpdateMetricConfigParams,
RecordMeasurementParams,
} from './quality-metrics.types.js';
/**
* QualityMetrics aggregate root
*
* Represents quality metrics for a project with configuration, thresholds, and history.
* Tracks metric values over time and evaluates compliance with thresholds.
*
* @example
* ```typescript
* const metrics = QualityMetrics.create({
* projectKey: asProjectKey('my-project'),
* repositoryId: asGraphQLNodeId('repo123'),
* configuration: {
* name: 'Line Coverage',
* shortcode: MetricShortcode.LCV,
* metricKey: MetricKey.AGGREGATE,
* unit: '%',
* minAllowed: 0,
* maxAllowed: 100,
* positiveDirection: 'UPWARD',
* isReported: true,
* isThresholdEnforced: true,
* threshold: ThresholdValue.createPercentage(80)
* }
* });
*
* metrics.recordMeasurement({ value: 85.5, commitOid: 'abc123' });
* console.log(metrics.isCompliant); // true
* ```
*/
export class QualityMetrics extends AggregateRoot<string> {
private _projectKey: ProjectKey;
private _repositoryId: GraphQLNodeId;
private _configuration: MetricConfiguration;
private _currentValue: MetricValue | null;
private _history: MetricHistoryEntry[];
private _lastUpdated: Date;
private static readonly MAX_HISTORY_ENTRIES = 100;
private constructor(
id: string,
projectKey: ProjectKey,
repositoryId: GraphQLNodeId,
configuration: MetricConfiguration,
currentValue: MetricValue | null,
history: MetricHistoryEntry[],
lastUpdated: Date
) {
super(id);
this._projectKey = projectKey;
this._repositoryId = repositoryId;
this._configuration = configuration;
this._currentValue = currentValue;
this._history = history;
this._lastUpdated = lastUpdated;
}
/**
* Creates a new QualityMetrics aggregate
*
* @param params - Creation parameters
* @returns A new QualityMetrics instance
*/
static create(params: CreateQualityMetricsParams): QualityMetrics {
const { projectKey, repositoryId, configuration, currentValue, history } = params;
// Validate configuration
if (!configuration.name || configuration.name.trim().length === 0) {
throw new Error('Metric name cannot be empty');
}
if (configuration.minAllowed >= configuration.maxAllowed) {
throw new Error('Min allowed value must be less than max allowed value');
}
// Create composite ID
const id = QualityMetrics.createId({
projectKey,
metricKey: configuration.metricKey,
shortcode: configuration.shortcode,
});
const now = new Date();
const metrics = new QualityMetrics(
id,
projectKey,
repositoryId,
configuration,
currentValue || null,
history || [],
now
);
metrics.addDomainEvent({
aggregateId: id,
eventType: 'QualityMetricsCreated',
occurredAt: now,
payload: {
projectKey,
metricKey: configuration.metricKey,
shortcode: configuration.shortcode,
threshold: configuration.threshold?.value,
},
});
return metrics;
}
/**
* Creates a composite ID for the aggregate
*/
private static createId(id: QualityMetricsId): string {
return `${id.projectKey}:${id.metricKey}:${id.shortcode}`;
}
/**
* Gets the project key
*/
get projectKey(): ProjectKey {
return this._projectKey;
}
/**
* Gets the repository ID
*/
get repositoryId(): GraphQLNodeId {
return this._repositoryId;
}
/**
* Gets the metric configuration
*/
get configuration(): Readonly<MetricConfiguration> {
return { ...this._configuration };
}
/**
* Gets the current metric value
*/
get currentValue(): MetricValue | null {
return this._currentValue;
}
/**
* Gets the metric history
*/
get history(): ReadonlyArray<MetricHistoryEntry> {
return [...this._history];
}
/**
* Gets the last update timestamp
*/
get lastUpdated(): Date {
return this._lastUpdated;
}
/**
* Checks if the metric is currently compliant with its threshold
*/
get isCompliant(): boolean {
if (!this._configuration.threshold || !this._currentValue) {
return true; // No threshold or no value means compliant
}
return this._configuration.threshold.isMet(
this._currentValue.value,
this._configuration.positiveDirection === 'UPWARD' ? 'upward' : 'downward'
);
}
/**
* Gets the current threshold status
*/
get thresholdStatus(): ThresholdStatus {
if (!this._configuration.threshold || !this._currentValue) {
return 'UNKNOWN';
}
return this.isCompliant ? 'PASSING' : 'FAILING';
}
/**
* Updates the metric threshold
*
* @param threshold - New threshold value or null to remove
*/
updateThreshold(threshold: ThresholdValue | null): void {
const oldThreshold = this._configuration.threshold;
if (threshold && threshold.unit !== this._configuration.unit) {
throw new Error(
`Threshold unit '${threshold.unit}' does not match metric unit '${this._configuration.unit}'`
);
}
if (
threshold &&
(threshold.value < this._configuration.minAllowed ||
threshold.value > this._configuration.maxAllowed)
) {
throw new Error('Threshold value is outside allowed range for this metric');
}
this._configuration.threshold = threshold;
this._lastUpdated = new Date();
this.addDomainEvent({
aggregateId: this._id,
eventType: 'MetricThresholdUpdated',
occurredAt: this._lastUpdated,
payload: {
oldThreshold: oldThreshold?.value,
newThreshold: threshold?.value,
unit: this._configuration.unit,
},
});
this.markAsModified();
}
/**
* Updates the metric configuration
*
* @param params - Configuration update parameters
*/
updateConfiguration(params: UpdateMetricConfigParams): void {
let hasChanges = false;
if (params.name !== undefined) {
const trimmedName = params.name.trim();
if (trimmedName.length === 0) {
throw new Error('Metric name cannot be empty');
}
if (trimmedName !== this._configuration.name) {
this._configuration.name = trimmedName;
hasChanges = true;
}
}
if (
params.description !== undefined &&
params.description !== this._configuration.description
) {
this._configuration.description = params.description;
hasChanges = true;
}
if (params.isReported !== undefined && params.isReported !== this._configuration.isReported) {
this._configuration.isReported = params.isReported;
hasChanges = true;
}
if (
params.isThresholdEnforced !== undefined &&
params.isThresholdEnforced !== this._configuration.isThresholdEnforced
) {
this._configuration.isThresholdEnforced = params.isThresholdEnforced;
hasChanges = true;
}
if (hasChanges) {
this._lastUpdated = new Date();
this.addDomainEvent({
aggregateId: this._id,
eventType: 'MetricConfigurationUpdated',
occurredAt: this._lastUpdated,
payload: params as Record<string, unknown>,
});
this.markAsModified();
}
}
/**
* Records a new measurement
*
* @param params - Measurement parameters
*/
recordMeasurement(params: RecordMeasurementParams): void {
const { value, commitOid, measuredAt } = params;
if (value < this._configuration.minAllowed || value > this._configuration.maxAllowed) {
throw new Error(
`Value ${value} is outside allowed range [${this._configuration.minAllowed}, ${this._configuration.maxAllowed}]`
);
}
const timestamp = measuredAt || new Date();
const metricValue = MetricValue.create(value, this._configuration.unit, undefined, timestamp);
// Update current value
this._currentValue = metricValue;
// Add to history
const historyEntry: MetricHistoryEntry = {
value: metricValue,
threshold: this._configuration.threshold,
thresholdStatus: this.thresholdStatus,
commitOid,
recordedAt: timestamp,
};
this._history.push(historyEntry);
// Trim history if needed
if (this._history.length > QualityMetrics.MAX_HISTORY_ENTRIES) {
this._history = this._history.slice(-QualityMetrics.MAX_HISTORY_ENTRIES);
}
this._lastUpdated = timestamp;
this.addDomainEvent({
aggregateId: this._id,
eventType: 'MeasurementRecorded',
occurredAt: timestamp,
payload: {
value,
unit: this._configuration.unit,
commitOid,
thresholdStatus: this.thresholdStatus,
},
});
this.markAsModified();
}
/**
* Evaluates compliance at a specific point in time
*
* @param value - The value to evaluate
* @returns Whether the value meets the threshold
*/
evaluateCompliance(value: number): boolean {
if (!this._configuration.threshold) {
return true;
}
return this._configuration.threshold.isMet(
value,
this._configuration.positiveDirection === 'UPWARD' ? 'upward' : 'downward'
);
}
/**
* Gets the trend over a specified period
*
* @param periodDays - Number of days to analyze
* @returns Trend information or null if insufficient data
*/
getTrend(periodDays = 30): MetricTrend | null {
if (this._history.length < 2) {
return null;
}
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - periodDays);
const recentHistory = this._history.filter((entry) => entry.recordedAt >= cutoffDate);
if (recentHistory.length < 2) {
return null;
}
const firstValue = recentHistory[0]?.value.value;
const lastValue = recentHistory[recentHistory.length - 1]?.value.value;
if (firstValue === undefined || lastValue === undefined) {
return null;
}
const changePercentage = ((lastValue - firstValue) / firstValue) * 100;
let direction: 'IMPROVING' | 'DEGRADING' | 'STABLE';
if (Math.abs(changePercentage) < 1) {
direction = 'STABLE';
} else if (
(this._configuration.positiveDirection === 'UPWARD' && changePercentage > 0) ||
(this._configuration.positiveDirection === 'DOWNWARD' && changePercentage < 0)
) {
direction = 'IMPROVING';
} else {
direction = 'DEGRADING';
}
return {
direction,
changePercentage,
periodDays,
};
}
/**
* Reconstructs QualityMetrics from persisted data
*
* @param data - Persisted metrics data
* @returns A reconstructed QualityMetrics instance
*/
static fromPersistence(data: {
id: string;
projectKey: ProjectKey;
repositoryId: GraphQLNodeId;
configuration: MetricConfiguration;
currentValue: MetricValue | null;
history: MetricHistoryEntry[];
lastUpdated: Date;
}): QualityMetrics {
return new QualityMetrics(
data.id,
data.projectKey,
data.repositoryId,
data.configuration,
data.currentValue,
data.history,
data.lastUpdated
);
}
/**
* Converts the metrics to a persistence-friendly format
*/
toPersistence(): {
id: string;
projectKey: ProjectKey;
repositoryId: GraphQLNodeId;
configuration: MetricConfiguration;
currentValue: MetricValue | null;
history: MetricHistoryEntry[];
lastUpdated: Date;
} {
return {
id: this._id,
projectKey: this._projectKey,
repositoryId: this._repositoryId,
configuration: { ...this._configuration },
currentValue: this._currentValue,
history: [...this._history],
lastUpdated: this._lastUpdated,
};
}
}