using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// A local change to queue.
/// Owning vault.
/// Kind of item.
/// The item. Client-generated UUIDv7, so items can be made offline.
/// Upsert or delete.
/// The version the client believes the server holds; null to create.
/// Ciphertext. Required for an upsert.
/// Plaintext columns the server needs.
///
/// The version this edit branched from, so a conflict can be merged rather than arbitrated. Null when
/// creating, where there is nothing to have branched from.
///
public sealed record QueuedChange(
Guid VaultId,
SyncEntityType EntityType,
Guid EntityId,
SyncOperation Operation,
int? ExpectedVersion,
EncryptedPayload? Payload,
SyncPlaintextFields? Fields,
StoredAncestor? Ancestor);
///
/// Changes made here that the server has not yet accepted.
///
///
///
/// One row per item, and that is a database constraint rather than a convention. Two queued edits to
/// one item would have to be pushed in order, and the second's expectedVersion is the version
/// the first will produce — which is not known when it is queued. Coalescing sidesteps that instead of
/// managing it, and the row holds a desired end state rather than a delta, so coalescing loses nothing.
///
///
/// Why a coalesced row gets a new operation id. The id is the server's exactly-once key. If a
/// push has already gone out and the user edits again, keeping the id would let the server answer
/// Duplicate for an operation whose contents have since changed — silently discarding the newer
/// edit. A fresh id means the newer state is offered on its own terms: if the earlier push did land,
/// the version has moved on, the push comes back Conflict, and the merge resolves it against an
/// ancestor that is this client's own earlier edit. That merge finds no disagreement, so it converges
/// on the newest state with nothing for the user to arbitrate.
///
///
/// ◆ Which is only true if an answer is applied to the operation it answers. A push reads a row,
/// sends it, and acts on the reply some time later; a user who saves in between coalesces a new state into
/// that same row. So , and
/// take the operation that was sent and do nothing when the row has since become a different one. Keyed by
/// sequence alone, an acknowledgement of the earlier state deleted the row holding the later one, and the
/// edit was gone with nothing to say so.
///
///
public sealed class OutboxStore(
IDbContextFactory contexts,
LocalCacheProtector protector,
TimeProvider clock)
{
///
/// Queues a change the user just made, coalescing into any row already pending for the item.
///
///
/// A coalesced row keeps the ancestor and expectedVersion of the row it replaces, because
/// the new state is still a descendant of that same base. Taking the caller's values instead would
/// throw away the common ancestor after the first edit, and with it the ability to merge.
///
public async Task QueueAsync(
QueuedChange change,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(change);
Validate(change);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await FindRowAsync(context, change.VaultId, change.EntityType, change.EntityId, cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new OutboxRow
{
VaultId = change.VaultId,
EntityType = change.EntityType,
EntityId = change.EntityId,
ExpectedVersion = change.ExpectedVersion,
};
SetAncestor(row, change.EntityType, change.EntityId, change.Ancestor);
context.Add(row);
}
row.OperationId = Guid.CreateVersion7();
row.Operation = change.Operation;
row.QueuedAtUtc = clock.GetUtcNow();
row.Attempts = 0;
row.LastError = null;
row.IsParked = false;
SetPayload(row, change.EntityType, change.EntityId, change.Payload, change.Fields);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return ToPending(row);
}
///
/// Replaces a pending operation with the outcome of a merge.
///
///
///
/// Distinct from because the intent is opposite: this does move
/// the ancestor forward, to the server version the merge was performed against. Without that the
/// re-push would conflict against the same base for ever.
///
///
/// It also keeps the attempt count, where queueing resets it. That difference is what makes
/// the retry bound real: a row that has conflicted five times needs a person to look at it whether
/// or not each attempt carried a freshly merged payload, whereas a user making a new edit has
/// genuinely started over.
///
///
public async Task ReviseAsync(
PendingOperation revising,
SyncOperation operation,
int? expectedVersion,
EncryptedPayload? payload,
SyncPlaintextFields? fields,
StoredAncestor? ancestor,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(revising);
var row = await context.Set()
.SingleOrDefaultAsync(
r => r.Sequence == revising.Sequence && r.OperationId == revising.OperationId,
cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
// Completed, or replaced by a newer edit since it was read. Either way the merge was of a state
// that is no longer the one queued, and the next push will meet the server with the current one.
return null;
}
row.OperationId = Guid.CreateVersion7();
row.Operation = operation;
row.ExpectedVersion = expectedVersion;
row.LastError = null;
row.IsParked = false;
SetPayload(row, row.EntityType, row.EntityId, payload, fields);
SetAncestor(row, row.EntityType, row.EntityId, ancestor);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return ToPending(row);
}
/// Reads the next operations to push, oldest first, skipping parked ones.
public async Task> TakeAsync(
Guid vaultId,
int limit,
CancellationToken cancellationToken)
{
ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var rows = await context.Set()
.AsNoTracking()
.Where(r => r.VaultId == vaultId && !r.IsParked)
.OrderBy(r => r.Sequence)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToPending)];
}
/// Reads the operation pending for one item, if any.
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 FindRowAsync(context, vaultId, entityType, entityId, cancellationToken)
.ConfigureAwait(false);
return row is null ? null : ToPending(row);
}
///
/// Reads every pending operation for a vault, parked ones included.
///
///
/// What the interface needs, as opposed to what the pusher needs. A parked change is still the
/// user's current intent for that item and must be what they see; hiding it because the server
/// refused it would show them the old values and look like their edit was lost.
///
public async Task> ListAllAsync(
Guid vaultId,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var rows = await context.Set()
.AsNoTracking()
.Where(r => r.VaultId == vaultId)
.OrderBy(r => r.Sequence)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToPending)];
}
/// Reads operations the server refused, which need a person.
public async Task> ListParkedAsync(
Guid vaultId,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var rows = await context.Set()
.AsNoTracking()
.Where(r => r.VaultId == vaultId && r.IsParked)
.OrderBy(r => r.Sequence)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToPending)];
}
/// Records that an operation has been sent, so a repeated failure can be noticed.
public Task MarkDispatchedAsync(long sequence, CancellationToken cancellationToken) =>
UpdateAsync(sequence, operationId: null, row => row.Attempts++, cancellationToken);
/// Removes an operation the server accepted, unless a newer edit has replaced it since.
/// Whether a row was removed.
public async Task CompleteAsync(PendingOperation operation, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(operation);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var removed = await context.Set()
.Where(r => r.Sequence == operation.Sequence && r.OperationId == operation.OperationId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
return removed > 0;
}
///
/// Stops retrying an operation and records why.
///
///
/// For the answers that will not change on retry — the server calling an operation structurally
/// invalid, or the caller no longer having permission. Retrying either would spin forever and, far
/// worse, would block every change queued behind it in a vault the user can still write to.
///
public Task ParkAsync(PendingOperation operation, string reason, CancellationToken cancellationToken) =>
UpdateAsync(
operation.Sequence,
operation.OperationId,
row =>
{
row.IsParked = true;
row.LastError = reason;
},
cancellationToken);
/// Records a transient failure without parking the operation.
public Task RecordFailureAsync(long sequence, string reason, CancellationToken cancellationToken) =>
UpdateAsync(sequence, operationId: null, row => row.LastError = reason, cancellationToken);
private static Task FindRowAsync(
ClientCacheContext context,
Guid vaultId,
SyncEntityType entityType,
Guid entityId,
CancellationToken cancellationToken) =>
context.Set()
.SingleOrDefaultAsync(
r => r.VaultId == vaultId && r.EntityType == entityType && r.EntityId == entityId,
cancellationToken);
private static void Validate(QueuedChange change)
{
if (change.Operation == SyncOperation.Upsert && change.Payload is null)
{
throw new ArgumentException("An upsert requires a payload.", nameof(change));
}
if (change.Operation == SyncOperation.Unspecified)
{
throw new ArgumentException("An operation is required.", nameof(change));
}
if (change.EntityId == Guid.Empty)
{
throw new ArgumentException("An entity id is required.", nameof(change));
}
}
private async Task UpdateAsync(
long sequence,
Guid? operationId,
Action mutate,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set()
.SingleOrDefaultAsync(
r => r.Sequence == sequence && (operationId == null || r.OperationId == operationId),
cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
return;
}
mutate(row);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
private void SetPayload(
OutboxRow row,
SyncEntityType entityType,
Guid entityId,
EncryptedPayload? payload,
SyncPlaintextFields? fields)
{
row.Payload = payload?.Envelope;
row.WrappedDataKey = payload?.WrappedDataKey;
row.DataKeyId = payload?.DataKeyId;
row.KeyGeneration = payload?.KeyGeneration ?? 0;
row.AadVersion = payload?.AadVersion ?? 0;
row.ProtectedFields = Seal(entityType, entityId, fields);
}
private void SetAncestor(
OutboxRow row,
SyncEntityType entityType,
Guid entityId,
StoredAncestor? ancestor)
{
row.AncestorVersion = ancestor?.Version;
row.AncestorPayload = ancestor?.Payload.Envelope;
row.AncestorWrappedDataKey = ancestor?.Payload.WrappedDataKey;
row.AncestorDataKeyId = ancestor?.Payload.DataKeyId;
row.AncestorKeyGeneration = ancestor?.Payload.KeyGeneration;
row.AncestorAadVersion = ancestor?.Payload.AadVersion;
row.AncestorProtectedFields = Seal(entityType, entityId, ancestor?.Fields);
}
private byte[]? Seal(SyncEntityType entityType, Guid entityId, SyncPlaintextFields? fields) =>
fields is null
? null
: protector.Protect(
AadResourceTypes.For(entityType), entityId, PlaintextFieldsCodec.Encode(fields));
private SyncPlaintextFields? Open(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);
}
private PendingOperation ToPending(OutboxRow row)
{
var ancestorPayload = CacheMapping.ToAncestorPayload(row);
var ancestor = ancestorPayload is null || row.AncestorVersion is null
? null
: new StoredAncestor(
row.AncestorVersion.Value,
ancestorPayload,
Open(row.EntityType, row.EntityId, row.AncestorProtectedFields));
return new PendingOperation(
row.Sequence,
row.OperationId,
row.VaultId,
row.EntityType,
row.EntityId,
row.Operation,
row.ExpectedVersion,
CacheMapping.ToPayload(
row.Payload, row.WrappedDataKey, row.DataKeyId, row.KeyGeneration, row.AadVersion),
Open(row.EntityType, row.EntityId, row.ProtectedFields),
ancestor,
row.QueuedAtUtc,
row.Attempts,
row.LastError,
row.IsParked);
}
}