Files
git.stella-ops.org/src/__Libraries/StellaOps.Facet/IFacetDriftVexDraftStore.cs

330 lines
9.5 KiB
C#

// <copyright file="IFacetDriftVexDraftStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
// Sprint: SPRINT_20260105_002_003_FACET (QTA-018)
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.Facet;
/// <summary>
/// Query parameters for listing VEX drafts.
/// </summary>
public sealed record FacetDriftVexDraftQuery
{
/// <summary>
/// Filter by image digest.
/// </summary>
public string? ImageDigest { get; init; }
/// <summary>
/// Filter by facet ID.
/// </summary>
public string? FacetId { get; init; }
/// <summary>
/// Filter by review status.
/// </summary>
public FacetDriftVexReviewStatus? ReviewStatus { get; init; }
/// <summary>
/// Include only drafts created since this time.
/// </summary>
public DateTimeOffset? Since { get; init; }
/// <summary>
/// Include only drafts created until this time.
/// </summary>
public DateTimeOffset? Until { get; init; }
/// <summary>
/// Maximum number of results to return.
/// </summary>
public int Limit { get; init; } = 100;
/// <summary>
/// Offset for pagination.
/// </summary>
public int Offset { get; init; } = 0;
}
/// <summary>
/// Review status for facet drift VEX drafts.
/// </summary>
public enum FacetDriftVexReviewStatus
{
/// <summary>
/// Draft is pending review.
/// </summary>
Pending,
/// <summary>
/// Draft has been approved.
/// </summary>
Approved,
/// <summary>
/// Draft has been rejected.
/// </summary>
Rejected,
/// <summary>
/// Draft has expired without review.
/// </summary>
Expired
}
/// <summary>
/// Storage abstraction for facet drift VEX drafts.
/// </summary>
public interface IFacetDriftVexDraftStore
{
/// <summary>
/// Saves a new draft. Throws if a draft with the same ID already exists.
/// </summary>
Task SaveAsync(FacetDriftVexDraft draft, CancellationToken ct = default);
/// <summary>
/// Saves multiple drafts atomically.
/// </summary>
Task SaveBatchAsync(IEnumerable<FacetDriftVexDraft> drafts, CancellationToken ct = default);
/// <summary>
/// Finds a draft by its unique ID.
/// </summary>
Task<FacetDriftVexDraft?> FindByIdAsync(string draftId, CancellationToken ct = default);
/// <summary>
/// Finds drafts matching the query parameters.
/// </summary>
Task<ImmutableArray<FacetDriftVexDraft>> QueryAsync(FacetDriftVexDraftQuery query, CancellationToken ct = default);
/// <summary>
/// Updates a draft's review status.
/// </summary>
Task UpdateReviewStatusAsync(
string draftId,
FacetDriftVexReviewStatus status,
string? reviewedBy = null,
string? reviewNotes = null,
CancellationToken ct = default);
/// <summary>
/// Gets pending drafts that have passed their review deadline.
/// </summary>
Task<ImmutableArray<FacetDriftVexDraft>> GetOverdueAsync(DateTimeOffset asOf, CancellationToken ct = default);
/// <summary>
/// Deletes expired drafts older than the retention period.
/// </summary>
Task<int> PurgeExpiredAsync(DateTimeOffset asOf, CancellationToken ct = default);
/// <summary>
/// Checks if a draft exists for the given image/facet combination.
/// </summary>
Task<bool> ExistsAsync(string imageDigest, string facetId, CancellationToken ct = default);
}
/// <summary>
/// Extended draft record with review tracking.
/// </summary>
public sealed record FacetDriftVexDraftWithReview
{
/// <summary>
/// The original draft.
/// </summary>
public required FacetDriftVexDraft Draft { get; init; }
/// <summary>
/// Current review status.
/// </summary>
public FacetDriftVexReviewStatus ReviewStatus { get; init; } = FacetDriftVexReviewStatus.Pending;
/// <summary>
/// Who reviewed the draft.
/// </summary>
public string? ReviewedBy { get; init; }
/// <summary>
/// When the draft was reviewed.
/// </summary>
public DateTimeOffset? ReviewedAt { get; init; }
/// <summary>
/// Notes from the reviewer.
/// </summary>
public string? ReviewNotes { get; init; }
}
/// <summary>
/// In-memory implementation of <see cref="IFacetDriftVexDraftStore"/> for testing.
/// </summary>
public sealed class InMemoryFacetDriftVexDraftStore : IFacetDriftVexDraftStore
{
private readonly ConcurrentDictionary<string, FacetDriftVexDraftWithReview> _drafts = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryFacetDriftVexDraftStore"/> class.
/// </summary>
public InMemoryFacetDriftVexDraftStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public Task SaveBatchAsync(IEnumerable<FacetDriftVexDraft> 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;
}
/// <inheritdoc />
public Task<FacetDriftVexDraft?> FindByIdAsync(string draftId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(draftId);
_drafts.TryGetValue(draftId, out var wrapper);
return Task.FromResult<FacetDriftVexDraft?>(wrapper?.Draft);
}
/// <inheritdoc />
public Task<ImmutableArray<FacetDriftVexDraft>> 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);
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public Task<ImmutableArray<FacetDriftVexDraft>> 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);
}
/// <inheritdoc />
public Task<int> 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);
}
/// <inheritdoc />
public Task<bool> 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);
}
/// <summary>
/// Gets all drafts for testing purposes.
/// </summary>
public IReadOnlyCollection<FacetDriftVexDraftWithReview> GetAllForTesting()
=> _drafts.Values.ToList();
}