//
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
//
// Sprint: SPRINT_20260105_002_003_FACET (QTA-018)
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.Facet;
///
/// Query parameters for listing VEX drafts.
///
public sealed record FacetDriftVexDraftQuery
{
///
/// Filter by image digest.
///
public string? ImageDigest { get; init; }
///
/// Filter by facet ID.
///
public string? FacetId { get; init; }
///
/// Filter by review status.
///
public FacetDriftVexReviewStatus? ReviewStatus { get; init; }
///
/// Include only drafts created since this time.
///
public DateTimeOffset? Since { get; init; }
///
/// Include only drafts created until this time.
///
public DateTimeOffset? Until { get; init; }
///
/// Maximum number of results to return.
///
public int Limit { get; init; } = 100;
///
/// Offset for pagination.
///
public int Offset { get; init; } = 0;
}
///
/// Review status for facet drift VEX drafts.
///
public enum FacetDriftVexReviewStatus
{
///
/// Draft is pending review.
///
Pending,
///
/// Draft has been approved.
///
Approved,
///
/// Draft has been rejected.
///
Rejected,
///
/// Draft has expired without review.
///
Expired
}
///
/// Storage abstraction for facet drift VEX drafts.
///
public interface IFacetDriftVexDraftStore
{
///
/// Saves a new draft. Throws if a draft with the same ID already exists.
///
Task SaveAsync(FacetDriftVexDraft draft, CancellationToken ct = default);
///
/// Saves multiple drafts atomically.
///
Task SaveBatchAsync(IEnumerable drafts, CancellationToken ct = default);
///
/// Finds a draft by its unique ID.
///
Task FindByIdAsync(string draftId, CancellationToken ct = default);
///
/// Finds drafts matching the query parameters.
///
Task> QueryAsync(FacetDriftVexDraftQuery query, CancellationToken ct = default);
///
/// Updates a draft's review status.
///
Task UpdateReviewStatusAsync(
string draftId,
FacetDriftVexReviewStatus status,
string? reviewedBy = null,
string? reviewNotes = null,
CancellationToken ct = default);
///
/// Gets pending drafts that have passed their review deadline.
///
Task> GetOverdueAsync(DateTimeOffset asOf, CancellationToken ct = default);
///
/// Deletes expired drafts older than the retention period.
///
Task PurgeExpiredAsync(DateTimeOffset asOf, CancellationToken ct = default);
///
/// Checks if a draft exists for the given image/facet combination.
///
Task ExistsAsync(string imageDigest, string facetId, CancellationToken ct = default);
}
///
/// Extended draft record with review tracking.
///
public sealed record FacetDriftVexDraftWithReview
{
///
/// The original draft.
///
public required FacetDriftVexDraft Draft { get; init; }
///
/// Current review status.
///
public FacetDriftVexReviewStatus ReviewStatus { get; init; } = FacetDriftVexReviewStatus.Pending;
///
/// Who reviewed the draft.
///
public string? ReviewedBy { get; init; }
///
/// When the draft was reviewed.
///
public DateTimeOffset? ReviewedAt { get; init; }
///
/// Notes from the reviewer.
///
public string? ReviewNotes { get; init; }
}
///
/// In-memory implementation of for testing.
///
public sealed class InMemoryFacetDriftVexDraftStore : IFacetDriftVexDraftStore
{
private readonly ConcurrentDictionary _drafts = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
///
/// Initializes a new instance of the class.
///
public InMemoryFacetDriftVexDraftStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
///
public Task SaveAsync(FacetDriftVexDraft draft, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(draft);
var wrapper = new FacetDriftVexDraftWithReview { Draft = draft };
if (!_drafts.TryAdd(draft.DraftId, wrapper))
{
throw new InvalidOperationException($"Draft with ID '{draft.DraftId}' already exists.");
}
return Task.CompletedTask;
}
///
public Task SaveBatchAsync(IEnumerable drafts, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(drafts);
foreach (var draft in drafts)
{
var wrapper = new FacetDriftVexDraftWithReview { Draft = draft };
if (!_drafts.TryAdd(draft.DraftId, wrapper))
{
throw new InvalidOperationException($"Draft with ID '{draft.DraftId}' already exists.");
}
}
return Task.CompletedTask;
}
///
public Task FindByIdAsync(string draftId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(draftId);
_drafts.TryGetValue(draftId, out var wrapper);
return Task.FromResult(wrapper?.Draft);
}
///
public Task> QueryAsync(FacetDriftVexDraftQuery query, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(query);
var results = _drafts.Values.AsEnumerable();
if (!string.IsNullOrEmpty(query.ImageDigest))
{
results = results.Where(w => w.Draft.ImageDigest == query.ImageDigest);
}
if (!string.IsNullOrEmpty(query.FacetId))
{
results = results.Where(w => w.Draft.FacetId == query.FacetId);
}
if (query.ReviewStatus.HasValue)
{
results = results.Where(w => w.ReviewStatus == query.ReviewStatus.Value);
}
if (query.Since.HasValue)
{
results = results.Where(w => w.Draft.GeneratedAt >= query.Since.Value);
}
if (query.Until.HasValue)
{
results = results.Where(w => w.Draft.GeneratedAt <= query.Until.Value);
}
var paged = results
.OrderByDescending(w => w.Draft.GeneratedAt)
.Skip(query.Offset)
.Take(query.Limit)
.Select(w => w.Draft)
.ToImmutableArray();
return Task.FromResult(paged);
}
///
public Task UpdateReviewStatusAsync(
string draftId,
FacetDriftVexReviewStatus status,
string? reviewedBy = null,
string? reviewNotes = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(draftId);
if (!_drafts.TryGetValue(draftId, out var wrapper))
{
throw new KeyNotFoundException($"Draft with ID '{draftId}' not found.");
}
var updated = wrapper with
{
ReviewStatus = status,
ReviewedBy = reviewedBy,
ReviewedAt = _timeProvider.GetUtcNow(),
ReviewNotes = reviewNotes
};
_drafts[draftId] = updated;
return Task.CompletedTask;
}
///
public Task> GetOverdueAsync(DateTimeOffset asOf, CancellationToken ct = default)
{
var overdue = _drafts.Values
.Where(w => w.ReviewStatus == FacetDriftVexReviewStatus.Pending)
.Where(w => w.Draft.ReviewDeadline < asOf)
.Select(w => w.Draft)
.ToImmutableArray();
return Task.FromResult(overdue);
}
///
public Task PurgeExpiredAsync(DateTimeOffset asOf, CancellationToken ct = default)
{
var expiredIds = _drafts
.Where(kvp => kvp.Value.Draft.ExpiresAt < asOf)
.Select(kvp => kvp.Key)
.ToList();
foreach (var id in expiredIds)
{
_drafts.TryRemove(id, out _);
}
return Task.FromResult(expiredIds.Count);
}
///
public Task ExistsAsync(string imageDigest, string facetId, CancellationToken ct = default)
{
var exists = _drafts.Values.Any(w =>
w.Draft.ImageDigest == imageDigest &&
w.Draft.FacetId == facetId &&
w.ReviewStatus == FacetDriftVexReviewStatus.Pending);
return Task.FromResult(exists);
}
///
/// Gets all drafts for testing purposes.
///
public IReadOnlyCollection GetAllForTesting()
=> _drafts.Values.ToList();
}