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