using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
///
/// Where each vault's pull has reached.
///
///
///
/// The cursor is stored exactly as the server issued it and is never parsed, constructed or adjusted.
/// It is opaque and integrity-tagged for a reason: a client that could synthesise one could ask to
/// resume from a position the server never granted, and a tampered cursor is rejected rather than
/// silently mis-serving a range.
///
///
/// A null cursor means "from the beginning", which is also the recovery path for a cache that has been
/// discarded or that failed to decrypt. Re-pulling from nothing is always safe; guessing a position is
/// not.
///
///
public sealed class SyncStateStore(IDbContextFactory contexts)
{
///
/// Reads a vault's position, or a fresh one starting from the beginning.
///
///
/// Never returns null. An unknown vault is not an error — it is a vault this client has not synced
/// yet — and a caller forced to handle a null here would most likely handle it by starting from the
/// beginning anyway.
///
public async Task ReadAsync(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
? new StoredSyncState(vaultId, Cursor: null, KeyGeneration: 0)
: new StoredSyncState(
row.VaultId,
row.Cursor,
row.KeyGeneration,
row.LastPulledAtUtc,
row.LastPushedAtUtc,
row.ServerTimeSkewMs);
}
/// Records a vault's position.
public async Task SaveAsync(StoredSyncState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set()
.SingleOrDefaultAsync(r => r.VaultId == state.VaultId, cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new SyncStateRow { VaultId = state.VaultId };
context.Add(row);
}
row.Cursor = state.Cursor;
row.KeyGeneration = state.KeyGeneration;
row.LastPulledAtUtc = state.LastPulledAt;
row.LastPushedAtUtc = state.LastPushedAt;
row.ServerTimeSkewMs = state.ServerTimeSkewMs;
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
///
/// Forgets a vault's position so the next pull starts over.
///
///
///
/// The remedy when the cache cannot be trusted — a key generation the client has no grant for, or
/// rows that will not decrypt. A full re-pull is cheap next to the alternative of reasoning about
/// which half of the cache is still valid.
///
///
/// The outbox is deliberately not cleared. Those rows are the only copy of changes the user
/// made and the server has not accepted; discarding them here would turn a recoverable cache
/// problem into lost work. They re-push against the re-pulled state, conflicting and merging where
/// they must.
///
///
public async Task ResetAsync(Guid vaultId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
await context.Set()
.Where(r => r.VaultId == vaultId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.Set()
.Where(r => r.VaultId == vaultId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
}