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