using DodoSSH.Contracts; using Microsoft.EntityFrameworkCore; namespace DodoSSH.Client.Storage; /// /// What the merge had to override, and what it could not process. /// /// /// /// This table is what makes automatic merging defensible. The merge picks a winner field by field, /// which is only acceptable because the loser lands here verbatim and gets shown. Without it, a /// field-level merge is last-writer-wins with a longer explanation. /// /// /// The detail is sealed under the LocalCacheKey, because it is the one place the cache deliberately /// holds decrypted vault content — a password someone typed that another edit displaced. It is exactly /// as sensitive as the item it came from and is treated that way. /// /// public sealed class ConflictStore( IDbContextFactory contexts, LocalCacheProtector protector, TimeProvider clock) { /// /// Records a conflict. /// /// /// The record's own id is generated here and the detail is bound to it, so one conflict's discarded /// values can never be read back against another's row. /// public async Task RecordAsync( Guid vaultId, SyncEntityType entityType, Guid entityId, ConflictKind kind, ReadOnlyMemory detail, CancellationToken cancellationToken) { if (kind == ConflictKind.Unspecified) { throw new ArgumentOutOfRangeException(nameof(kind), kind, "A conflict kind is required."); } var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); var id = Guid.CreateVersion7(); context.Add(new ConflictRow { Id = id, VaultId = vaultId, EntityType = entityType, EntityId = entityId, Kind = kind, Detail = protector.Protect(AadResourceTypes.For(entityType), id, detail.Span), DetectedAtUtc = clock.GetUtcNow(), Acknowledged = false, }); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return id; } /// Reads conflicts for a vault, newest first. public async Task> ListAsync( Guid vaultId, bool includeAcknowledged, CancellationToken cancellationToken) { var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); var query = context.Set() .AsNoTracking() .Where(row => row.VaultId == vaultId); if (!includeAcknowledged) { query = query.Where(row => !row.Acknowledged); } var rows = await query .OrderByDescending(row => row.DetectedAtUtc) .ToListAsync(cancellationToken) .ConfigureAwait(false); return [.. rows.Select(ToStored)]; } /// Marks a conflict as dealt with. /// /// Acknowledged rather than deleted, so the discarded value stays recoverable after the user has /// dismissed the notification. Someone who clicks past a warning and realises a minute later that /// they wanted the other value should still be able to get it. /// public async Task AcknowledgeAsync(Guid conflictId, CancellationToken cancellationToken) { var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); var updated = await context.Set() .Where(row => row.Id == conflictId) .ExecuteUpdateAsync(row => row.SetProperty(r => r.Acknowledged, true), cancellationToken) .ConfigureAwait(false); return updated > 0; } /// Removes an acknowledged conflict for good. public async Task DiscardAsync(Guid conflictId, CancellationToken cancellationToken) { var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); var removed = await context.Set() .Where(row => row.Id == conflictId && row.Acknowledged) .ExecuteDeleteAsync(cancellationToken) .ConfigureAwait(false); return removed > 0; } /// /// A detail that will not open surfaces as empty rather than as a failure. The conflict itself — its /// kind, its item, its timestamp — is still worth showing even when the discarded value has become /// unreadable, for instance after a passphrase change re-derived the cache key. /// private StoredConflict ToStored(ConflictRow row) => new( row.Id, row.VaultId, row.EntityType, row.EntityId, row.Kind, protector.TryUnprotect(AadResourceTypes.For(row.EntityType), row.Id, row.Detail) ?? [], row.DetectedAtUtc, row.Acknowledged); }