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