using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; namespace StellaOps.Concelier.Storage.Mongo.Exporting; /// /// Helper for exporters to read and persist their export metadata in Mongo-backed storage. /// public sealed class ExportStateManager { private readonly IExportStateStore _store; private readonly TimeProvider _timeProvider; public ExportStateManager(IExportStateStore store, TimeProvider? timeProvider = null) { _store = store ?? throw new ArgumentNullException(nameof(store)); _timeProvider = timeProvider ?? TimeProvider.System; } public Task GetAsync(string exporterId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(exporterId); return _store.FindAsync(exporterId, cancellationToken); } public async Task StoreFullExportAsync( string exporterId, string exportId, string exportDigest, string? cursor, string? targetRepository, string exporterVersion, bool resetBaseline, IReadOnlyList manifest, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(exporterId); ArgumentException.ThrowIfNullOrEmpty(exportId); ArgumentException.ThrowIfNullOrEmpty(exportDigest); ArgumentException.ThrowIfNullOrEmpty(exporterVersion); manifest ??= Array.Empty(); var existing = await _store.FindAsync(exporterId, cancellationToken).ConfigureAwait(false); var now = _timeProvider.GetUtcNow(); if (existing is null) { var resolvedRepository = string.IsNullOrWhiteSpace(targetRepository) ? null : targetRepository; return await _store.UpsertAsync( new ExportStateRecord( exporterId, BaseExportId: exportId, BaseDigest: exportDigest, LastFullDigest: exportDigest, LastDeltaDigest: null, ExportCursor: cursor ?? exportDigest, TargetRepository: resolvedRepository, ExporterVersion: exporterVersion, UpdatedAt: now, Files: manifest), cancellationToken).ConfigureAwait(false); } var repositorySpecified = !string.IsNullOrWhiteSpace(targetRepository); var resolvedRepo = repositorySpecified ? targetRepository : existing.TargetRepository; var repositoryChanged = repositorySpecified && !string.Equals(existing.TargetRepository, targetRepository, StringComparison.Ordinal); var shouldResetBaseline = resetBaseline || string.IsNullOrWhiteSpace(existing.BaseExportId) || string.IsNullOrWhiteSpace(existing.BaseDigest) || repositoryChanged; var updatedRecord = shouldResetBaseline ? existing with { BaseExportId = exportId, BaseDigest = exportDigest, LastFullDigest = exportDigest, LastDeltaDigest = null, ExportCursor = cursor ?? exportDigest, TargetRepository = resolvedRepo, ExporterVersion = exporterVersion, UpdatedAt = now, Files = manifest, } : existing with { LastFullDigest = exportDigest, LastDeltaDigest = null, ExportCursor = cursor ?? existing.ExportCursor, TargetRepository = resolvedRepo, ExporterVersion = exporterVersion, UpdatedAt = now, Files = manifest, }; return await _store.UpsertAsync(updatedRecord, cancellationToken).ConfigureAwait(false); } public async Task StoreDeltaExportAsync( string exporterId, string deltaDigest, string? cursor, string exporterVersion, IReadOnlyList manifest, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(exporterId); ArgumentException.ThrowIfNullOrEmpty(deltaDigest); ArgumentException.ThrowIfNullOrEmpty(exporterVersion); manifest ??= Array.Empty(); var existing = await _store.FindAsync(exporterId, cancellationToken).ConfigureAwait(false); if (existing is null) { throw new InvalidOperationException($"Full export state missing for '{exporterId}'."); } var now = _timeProvider.GetUtcNow(); var record = existing with { LastDeltaDigest = deltaDigest, ExportCursor = cursor ?? existing.ExportCursor, ExporterVersion = exporterVersion, UpdatedAt = now, Files = manifest, }; return await _store.UpsertAsync(record, cancellationToken).ConfigureAwait(false); } }