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);
}