sprints and audit work
This commit is contained in:
329
src/__Libraries/StellaOps.Facet/IFacetDriftVexDraftStore.cs
Normal file
329
src/__Libraries/StellaOps.Facet/IFacetDriftVexDraftStore.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
// <copyright file="IFacetDriftVexDraftStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </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();
|
||||
}
|
||||
Reference in New Issue
Block a user