using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
///
/// The mirror of what the server holds.
///
///
///
/// Every row here is server-confirmed state. Nothing the user has typed but not yet pushed appears in
/// this table — that lives in , 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.
///
///
/// Requires an unlocked , 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.
///
///
public sealed class ItemStore(
IDbContextFactory contexts,
LocalCacheProtector protector)
{
/// Reads one item, tombstones included.
public async Task 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()
.AsNoTracking()
.SingleOrDefaultAsync(
r => r.VaultId == vaultId && r.EntityType == entityType && r.EntityId == entityId,
cancellationToken)
.ConfigureAwait(false);
return row is null ? null : ToStored(row);
}
/// Reads every item of one kind in a vault.
/// The vault.
/// Kind of item.
///
/// 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.
///
/// Cancellation token.
public async Task> ListAsync(
Guid vaultId,
SyncEntityType entityType,
bool includeDeleted,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var query = context.Set()
.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)];
}
///
/// Writes the server's version of an item, creating or replacing the row.
///
///
/// 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.
///
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()
.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);
}
///
/// Writes the server's version of many items at once, in one transaction, in the order given.
///
///
///
/// 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.
///
///
/// 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.
///
///
public async Task SaveManyAsync(IReadOnlyCollection 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()
.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);
}
/// Marks the items a pull from nothing has just read as ones the server still has.
/// One update per type in the page; see .
public async Task MarkSeenAsync(
Guid vaultId,
long mark,
IReadOnlyCollection<(SyncEntityType Type, Guid Id)> seen,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(seen);
if (seen.Count == 0)
{
return;
}
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
foreach (var group in seen.GroupBy(item => item.Type))
{
var type = group.Key;
var ids = group.Select(item => item.Id).Distinct().ToList();
await context.Set()
.Where(row => row.VaultId == vaultId && row.EntityType == type && ids.Contains(row.EntityId))
.ExecuteUpdateAsync(set => set.SetProperty(row => row.SweepMark, mark), cancellationToken)
.ConfigureAwait(false);
}
}
/// The highest change sequence held for a vault, or zero for none.
/// Where a pull from nothing starts its sweep's upper bound; see .
public async Task HighestSequenceAsync(Guid vaultId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set()
.Where(row => row.VaultId == vaultId)
.MaxAsync(row => (long?)row.ChangeSequence, cancellationToken)
.ConfigureAwait(false) ?? 0;
}
///
/// Removes the items a completed pull from nothing did not see, which are the ones the server no longer has.
///
///
///
/// The other half of the server's tombstone collection. A machine that missed a deletion for longer than
/// the retention is refused its cursor and pulls everything again — and everything again does not include
/// the item, so its copy here would otherwise last for ever, and its next edit would push it back.
///
///
/// ◆ Two kinds of row are not evidence and are kept. One newer than was
/// written by this machine's own push during the pull: the bound is the higher of what the cache held
/// when the pull began and what it has read since, and the server's head is past both, so a push lands
/// above it. One with a change still waiting to go is the user's work: it is
/// left for the push, which finds the server without the item and says so — see
/// SyncEngine.RetryAsCreateAsync — rather than being thrown away here unseen.
///
///
/// How many items went.
public async Task SweepAsync(
Guid vaultId,
IReadOnlyCollection types,
long mark,
long seenUpTo,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(types);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
// Only the types the pull asked for: a row of any other type was never going to be marked.
return await context.Set()
.Where(row => row.VaultId == vaultId
&& types.Contains(row.EntityType)
&& (row.SweepMark == null || row.SweepMark != mark)
&& row.ChangeSequence <= seenUpTo
&& !context.Set().Any(pending =>
pending.VaultId == row.VaultId
&& pending.EntityType == row.EntityType
&& pending.EntityId == row.EntityId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
/// Removes a tombstone whose change has been seen by everything that needed it.
///
/// 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.
///
public async Task CollectTombstonesAsync(
Guid vaultId,
DateTimeOffset olderThan,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set()
.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);
///
/// 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.
///
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);
}
}