Files
DodoSSH/src/DodoSSH.Client.Storage/ItemStore.cs
T

252 lines
9.3 KiB
C#

using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// <summary>
/// The mirror of what the server holds.
/// </summary>
/// <remarks>
/// <para>
/// Every row here is server-confirmed state. Nothing the user has typed but not yet pushed appears in
/// this table — that lives in <see cref="OutboxStore"/>, together with the ancestor it branched from.
/// Keeping the two apart is what makes a three-way merge possible: a single table holding "the current
/// local view" would have overwritten the ancestor and left only a two-way diff, which cannot tell an
/// edit from a revert.
/// </para>
/// <para>
/// Requires an unlocked <see cref="LocalCacheProtector"/>, which is deliberate. The protected columns
/// have to be sealed on every write and opened on every read, and a store that could be constructed
/// without a key would be a store that could write one of them in the clear.
/// </para>
/// </remarks>
public sealed class ItemStore(
IDbContextFactory<ClientCacheContext> contexts,
LocalCacheProtector protector)
{
/// <summary>Reads one item, tombstones included.</summary>
public async Task<StoredItem?> FindAsync(
Guid vaultId,
SyncEntityType entityType,
Guid entityId,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<CachedItemRow>()
.AsNoTracking()
.SingleOrDefaultAsync(
r => r.VaultId == vaultId && r.EntityType == entityType && r.EntityId == entityId,
cancellationToken)
.ConfigureAwait(false);
return row is null ? null : ToStored(row);
}
/// <summary>Reads every item of one kind in a vault.</summary>
/// <param name="vaultId">The vault.</param>
/// <param name="entityType">Kind of item.</param>
/// <param name="includeDeleted">
/// Whether to return tombstones. The interface wants them excluded; the sync engine wants them,
/// because a tombstone is the only record that an item it once knew about has gone.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<IReadOnlyList<StoredItem>> ListAsync(
Guid vaultId,
SyncEntityType entityType,
bool includeDeleted,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var query = context.Set<CachedItemRow>()
.AsNoTracking()
.Where(r => r.VaultId == vaultId && r.EntityType == entityType);
if (!includeDeleted)
{
query = query.Where(r => !r.IsDeleted);
}
var rows = await query
.OrderBy(r => r.ChangeSequence)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToStored)];
}
/// <summary>
/// Writes the server's version of an item, creating or replacing the row.
/// </summary>
/// <remarks>
/// Deliberately a blind overwrite. This is a mirror, and the server's answer is the truth about
/// what the server holds; a local edit that must survive is in the outbox, and it is the sync
/// engine's job to have merged it before calling this.
/// </remarks>
public async Task SaveAsync(StoredItem item, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(item);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<CachedItemRow>()
.SingleOrDefaultAsync(
r => r.VaultId == item.VaultId
&& r.EntityType == item.EntityType
&& r.EntityId == item.EntityId,
cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new CachedItemRow
{
VaultId = item.VaultId,
EntityType = item.EntityType,
EntityId = item.EntityId,
};
context.Add(row);
}
Apply(row, item);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Writes the server's version of many items at once, in one transaction, in the order given.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="SaveAsync"/> for a page of pulled changes. A pull used to write each change on its own —
/// a context, a read, a commit — so a first sync of a keychain with a few thousand items was a few
/// thousand SQLite transactions, each one a sync to disk. This is one read for the rows that exist
/// already and one commit for the lot.
/// </para>
/// <para>
/// The same blind overwrite, and the same answer when one item appears more than once: they are applied
/// in order to the same row, so the last one given is the one kept — exactly what writing them one by one
/// would have left.
/// </para>
/// </remarks>
public async Task SaveManyAsync(IReadOnlyCollection<StoredItem> items, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(items);
if (items.Count == 0)
{
return;
}
var vaultIds = items.Select(item => item.VaultId).Distinct().ToList();
var entityIds = items.Select(item => item.EntityId).Distinct().ToList();
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var existing = await context.Set<CachedItemRow>()
.Where(r => vaultIds.Contains(r.VaultId) && entityIds.Contains(r.EntityId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var rows = existing.ToDictionary(row => (row.VaultId, row.EntityType, row.EntityId));
foreach (var item in items)
{
if (!rows.TryGetValue((item.VaultId, item.EntityType, item.EntityId), out var row))
{
row = new CachedItemRow
{
VaultId = item.VaultId,
EntityType = item.EntityType,
EntityId = item.EntityId,
};
context.Add(row);
rows.Add((item.VaultId, item.EntityType, item.EntityId), row);
}
Apply(row, item);
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Removes a tombstone whose change has been seen by everything that needed it.</summary>
/// <remarks>
/// Only ever called for a row that is already a tombstone. Collecting a live item here would make
/// it indistinguishable from one this client has never seen, and it would silently reappear on the
/// next full pull.
/// </remarks>
public async Task<int> CollectTombstonesAsync(
Guid vaultId,
DateTimeOffset olderThan,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set<CachedItemRow>()
.Where(r => r.VaultId == vaultId && r.IsDeleted && r.UpdatedAtUtc < olderThan)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
private void Apply(CachedItemRow row, StoredItem item)
{
row.Version = item.Version;
row.ChangeSequence = item.ChangeSequence;
row.IsDeleted = item.IsDeleted;
row.UpdatedAtUtc = item.UpdatedAt;
row.Payload = item.Payload?.Envelope;
row.WrappedDataKey = item.Payload?.WrappedDataKey;
row.DataKeyId = item.Payload?.DataKeyId;
row.KeyGeneration = item.Payload?.KeyGeneration ?? 0;
row.AadVersion = item.Payload?.AadVersion ?? 0;
row.ProtectedFields = item.Fields is null
? null
: protector.Protect(
AadResourceTypes.For(item.EntityType),
item.EntityId,
PlaintextFieldsCodec.Encode(item.Fields));
}
private StoredItem ToStored(CachedItemRow row) =>
new(
row.VaultId,
row.EntityType,
row.EntityId,
row.Version,
row.ChangeSequence,
CacheMapping.ToPayload(
row.Payload, row.WrappedDataKey, row.DataKeyId, row.KeyGeneration, row.AadVersion),
OpenFields(row.EntityType, row.EntityId, row.ProtectedFields),
row.IsDeleted,
row.UpdatedAtUtc);
/// <remarks>
/// A record that will not open is treated as absent rather than fatal. The cache is not the
/// authority — a re-pull restores it — and the alternative is one stale row aborting a sync pass
/// and stranding every change behind it.
/// </remarks>
private SyncPlaintextFields? OpenFields(SyncEntityType entityType, Guid entityId, byte[]? sealedFields)
{
if (sealedFields is null)
{
return null;
}
var plaintext = protector.TryUnprotect(
AadResourceTypes.For(entityType), entityId, sealedFields);
return plaintext is null ? null : PlaintextFieldsCodec.TryDecode(plaintext);
}
}