using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
///
/// The vaults this user can reach, and the grants that open them.
///
///
/// Cached for the same reason as the unlock material: without the wrapped vault key on disk, an offline
/// launch could unlock the identity bundle and still not decrypt a single item. Every value here is
/// either public metadata or ciphertext.
///
public sealed class VaultStore(IDbContextFactory contexts, TimeProvider clock)
{
/// Reads every known vault.
public async Task> ListAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var rows = await context.Set()
.AsNoTracking()
.OrderByDescending(row => row.IsPersonal)
.ThenBy(row => row.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToStored)];
}
/// Reads one vault.
public async Task FindAsync(Guid vaultId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set()
.AsNoTracking()
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
.ConfigureAwait(false);
return row is null ? null : ToStored(row);
}
///
/// Replaces the cached vault list with what the server reported.
///
///
///
/// Vaults absent from the list are removed, because losing access to a vault is exactly what that
/// absence means and a stale row would offer the user a vault they can no longer sync.
///
///
/// Their items are a separate matter and are not touched here. Removing a member does not
/// retroactively erase what they already hold — that is not achievable, which is why offboarding
/// means rotating the SSH credential rather than revoking a grant. Deleting the local rows here
/// would only make the client pretend otherwise.
///
///
public async Task ReplaceAllAsync(
IReadOnlyList vaults,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(vaults);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var existing = await context.Set()
.ToDictionaryAsync(row => row.VaultId, cancellationToken)
.ConfigureAwait(false);
var now = clock.GetUtcNow();
foreach (var vault in vaults)
{
if (!existing.Remove(vault.VaultId, out var row))
{
row = new CachedVaultRow { VaultId = vault.VaultId };
context.Add(row);
}
Apply(row, vault, now);
}
context.RemoveRange(existing.Values);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
///
/// Records one vault, leaving the rest alone.
///
///
/// For the vault this machine has just created, which exists here before the server's next
/// /me confirms it. would be wrong for that: it treats absence
/// as loss of access, and the one list that does not yet mention this vault is the one this client
/// last fetched.
///
public async Task UpsertAsync(StoredVault vault, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(vault);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set()
.SingleOrDefaultAsync(r => r.VaultId == vault.VaultId, cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new CachedVaultRow { VaultId = vault.VaultId };
context.Add(row);
}
Apply(row, vault, clock.GetUtcNow());
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// Reads the vaults this machine has been asked to leave off the screens.
///
/// The ids alone: a caller wanting the names already has , and a list that
/// carried them would invite somebody to build a vault list out of this one, which is the list that
/// must never decide what syncs.
///
public async Task> ListHiddenAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set()
.AsNoTracking()
.Where(row => row.Hidden)
.Select(row => row.VaultId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// Records whether one vault's items are shown.
///
/// A vault with no row here is not an error and not worth reporting: a grant withdrawn between the
/// click and this write leaves nothing to record a preference about, and the vault is already gone
/// from every list the preference would have applied to.
///
public async Task SetHiddenAsync(Guid vaultId, bool hidden, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set()
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
.ConfigureAwait(false);
if (row is null || row.Hidden == hidden)
{
return;
}
row.Hidden = hidden;
row.UpdatedAtUtc = clock.GetUtcNow();
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
///
/// is deliberately not among these. This method exists to write
/// what the server said, and the server has never been told which vaults this machine is currently
/// showing — so a refresh that touched the flag would be a refresh that silently un-hid every vault,
/// once a minute. Leaving it out is what makes the preference survive .
///
private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now)
{
row.Name = vault.Name;
row.IsPersonal = vault.IsPersonal;
row.TeamId = vault.TeamId;
row.KeyGeneration = vault.KeyGeneration;
row.Permissions = vault.Permissions;
row.WrappedVaultKey = vault.WrappedVaultKey;
row.RekeyRequired = vault.RekeyRequired;
row.UpdatedAtUtc = now;
}
private static StoredVault ToStored(CachedVaultRow row) =>
new(
row.VaultId,
row.Name,
row.IsPersonal,
row.TeamId,
row.KeyGeneration,
row.Permissions,
row.WrappedVaultKey,
row.RekeyRequired);
}