using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Domain.Sync;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Npgsql;
namespace DodoSSH.Api.Features.Sync;
/// Applies and reads a vault's change log.
internal sealed class SyncService(
DodoDbContext database,
IOptions syncOptions,
ICursorKeyProvider cursorKeys,
TimeProvider clock,
ILogger logger)
{
private readonly SyncOptions options = syncOptions.Value;
/// Reads changes after a cursor.
internal async Task PullAsync(
Vault vault,
SyncPullRequest request,
CancellationToken cancellationToken)
{
var afterSequence = 0L;
var issuedUnder = 0L;
if (!string.IsNullOrEmpty(request.Cursor)
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence, out issuedUnder))
{
throw new InvalidCursorException();
}
// Read before the changes, for the cursor this read hands out; see the check after it.
var horizonBefore = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
var (changes, hasMore) = await ReadPageAsync(vault.Id, afterSequence, request, cancellationToken)
.ConfigureAwait(false);
// ◆ After the read, not before it. The collector deletes a tombstone's entries and raises the horizon
// in one transaction, so a read that already missed an entry is followed by a horizon that says so;
// checked first, the two could straddle a collection and the deletion would go unnoticed for good.
//
// A cursor behind the horizon is refused only if it was issued before it. One issued since belongs to
// a read from the beginning that started after the collection, and its early pages are behind the
// horizon because old, untouched items are; refusing those would send that read back to the start
// for ever. See SyncCursor.
var horizon = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
if (afterSequence > 0 && afterSequence < horizon && issuedUnder < horizon)
{
throw new InvalidCursorException();
}
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
// When nothing came back the cursor must not move, or a concurrent write landing between
// this read and the next would be skipped forever.
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
return new SyncPullResponse(
Changes: hydrated,
// The horizon from before the read, never the one after: a collection landing between the two
// could have taken an entry this page would otherwise have carried, and a cursor claiming the
// later horizon would vouch for a client that never heard of it. The earlier one costs at most
// a restart.
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence, horizonBefore),
HasMore: hasMore,
ServerTime: clock.GetUtcNow(),
CurrentKeyGeneration: (uint)vault.KeyGeneration);
}
/// The changes after a position, one page of them, and whether there are more.
private async Task<(List Changes, bool HasMore)> ReadPageAsync(
Guid vaultId,
long afterSequence,
SyncPullRequest request,
CancellationToken cancellationToken)
{
var limit = Math.Clamp(
request.Limit ?? options.DefaultPullLimit,
1,
options.MaxPullLimit);
var types = request.EntityTypes is { Count: > 0 }
? request.EntityTypes.Select(ToDomain).ToArray()
: null;
// One extra row, so "is there more" needs no second query.
var query = database.VaultChanges
.Where(c => c.VaultId == vaultId && c.Sequence > afterSequence);
if (types is not null)
{
query = query.Where(c => types.Contains(c.EntityType));
}
var changes = await query
.OrderBy(c => c.Sequence)
.Take(limit + 1)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var hasMore = changes.Count > limit;
if (hasMore)
{
changes.RemoveAt(changes.Count - 1);
}
return (changes, hasMore);
}
///
/// Applies a batch of operations.
///
///
///
/// The whole batch runs in one transaction whose first statement takes a per-vault
/// advisory lock. That is load-bearing: bigserial hands out sequence values before
/// commit, so without serialising writers per vault, transaction A can take sequence 5 while B
/// takes 6 and commits first. A reader advancing its cursor to 6 then misses 5 permanently.
/// See ADR 0003.
///
///
/// Individual operations that conflict are skipped rather than aborting the batch, so one stale
/// item cannot block everything else a client queued while offline.
///
///
internal async Task PushAsync(
Vault vault,
Guid actorUserId,
SyncPushRequest request,
CancellationToken cancellationToken)
{
ValidateBatchLimits(request);
return await database.ExecuteFreshAsync([vault], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
await AcquireVaultLockAsync(vault.Id, cancellationToken).ConfigureAwait(false);
var results = new List(request.Operations.Count);
var highestSequence = 0L;
foreach (var operation in request.Operations)
{
var result = await ApplyAsync(vault, actorUserId, operation, cancellationToken)
.ConfigureAwait(false);
results.Add(result);
if (result.ChangeSequence is { } sequence && sequence > highestSequence)
{
highestSequence = sequence;
}
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
// If nothing was applied, report the log head so the client does not rewind.
if (highestSequence == 0)
{
highestSequence = await CurrentHeadAsync(vault.Id, cancellationToken)
.ConfigureAwait(false);
}
return new SyncPushResponse(
Results: results,
Cursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, highestSequence));
}).ConfigureAwait(false);
}
private void ValidateBatchLimits(SyncPushRequest request)
{
if (request.Operations.Count == 0)
{
throw new PushBatchInvalidException("A push must contain at least one operation.");
}
if (request.Operations.Count > options.MaxOperationsPerPush)
{
throw new PushBatchTooLargeException(
$"A push may contain at most {options.MaxOperationsPerPush} operations; "
+ $"{request.Operations.Count} were supplied.");
}
long total = 0;
foreach (var operation in request.Operations)
{
var length = operation.Payload?.Envelope.Length ?? 0;
if (length > options.MaxItemPayloadBytes)
{
throw new PushBatchTooLargeException(
$"Item {operation.EntityId} payload is {length} bytes; the limit is "
+ $"{options.MaxItemPayloadBytes}.");
}
total += length;
}
if (total > options.MaxPayloadBytes)
{
throw new PushBatchTooLargeException(
$"Total push payload is {total} bytes; the limit is {options.MaxPayloadBytes}.");
}
}
///
/// Serialises writers to one vault for the life of the transaction.
///
///
/// hashtextextended over the vault id gives a stable 64-bit lock key. Contention is
/// per-vault, and a push is a single transaction anyway, so the cost is negligible next to the
/// silent sync corruption it prevents. Requires Multiplexing=false on the connection,
/// which is the Npgsql default — enabling it would break the lock's session affinity.
///
private Task AcquireVaultLockAsync(Guid vaultId, CancellationToken cancellationToken) =>
database.Database.ExecuteSqlAsync(
$"SELECT pg_advisory_xact_lock(hashtextextended({vaultId.ToString()}, 0))",
cancellationToken);
private async Task ApplyAsync(
Vault vault,
Guid actorUserId,
SyncPushOperation operation,
CancellationToken cancellationToken)
{
if (ItemKinds.For(operation.EntityType) is not { } kind)
{
// Reserved in the contract but not synced here yet. A newer client gets a precise
// per-operation answer rather than a whole-batch failure.
return Invalid(operation, $"Entity type {operation.EntityType} is not yet supported.");
}
if (operation.Operation is not (SyncOperation.Upsert or SyncOperation.Delete))
{
return Invalid(operation, "Operation must be Upsert or Delete.");
}
// Exactly-once at operation granularity. A batch-level idempotency key alone would
// double-apply the operations that did land when a client retries a partially-overlapping
// batch after a timeout.
var receipt = await database.SyncOperationReceipts
.SingleOrDefaultAsync(r => r.OperationId == operation.OperationId, cancellationToken)
.ConfigureAwait(false);
if (receipt is not null)
{
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Duplicate,
receipt.ResultVersion,
receipt.AppliedChangeSequence,
ServerEntity: null,
Detail: null);
}
var item = await kind.FindAsync(database, operation.EntityId, cancellationToken)
.ConfigureAwait(false);
// An id that exists in another vault must not be addressable from this one, and must not
// reveal that it exists elsewhere.
if (item is not null && item.VaultId != vault.Id)
{
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Forbidden,
Version: null,
ChangeSequence: null,
ServerEntity: null,
Detail: null);
}
return operation.Operation == SyncOperation.Delete
? await ApplyDeleteAsync(vault, kind, actorUserId, operation, item, cancellationToken)
.ConfigureAwait(false)
: await ApplyUpsertAsync(vault, kind, actorUserId, operation, item, cancellationToken)
.ConfigureAwait(false);
}
private async Task ApplyUpsertAsync(
Vault vault,
IItemKind kind,
Guid actorUserId,
SyncPushOperation operation,
IVaultItem? existing,
CancellationToken cancellationToken)
{
if (operation.Payload is null)
{
return Invalid(operation, "An upsert requires a payload.");
}
if (!ValidatePayload(operation.Payload, out var payloadError))
{
return Invalid(operation, payloadError);
}
var fields = operation.PlaintextFields ?? new SyncPlaintextFields();
if (!kind.ValidateFields(fields, out var fieldError))
{
return Invalid(operation, fieldError);
}
var now = clock.GetUtcNow();
if (existing is null || existing.DeletedAtUtc is not null)
{
return await CreateAsync(
vault, kind, actorUserId, operation, existing, fields, now, cancellationToken)
.ConfigureAwait(false);
}
if (operation.ExpectedVersion != existing.Version)
{
return await ConflictAsync(vault, kind, operation, existing, cancellationToken)
.ConfigureAwait(false);
}
existing.Version++;
ApplyFields(kind, existing, operation.Payload, fields, actorUserId, now);
return await RecordAsync(
vault, kind, actorUserId, operation, existing, ChangeOperation.Upsert, cancellationToken)
.ConfigureAwait(false);
}
/// Creates a new item, or rejects an upsert that cannot become one.
private async Task CreateAsync(
Vault vault,
IItemKind kind,
Guid actorUserId,
SyncPushOperation operation,
IVaultItem? existing,
SyncPlaintextFields fields,
DateTimeOffset now,
CancellationToken cancellationToken)
{
// A tombstone wins over a late upsert. The client is told, so it can resurrect the item
// deliberately under a new id rather than silently undoing someone else's delete.
if (existing?.DeletedAtUtc is not null)
{
return await ConflictAsync(vault, kind, operation, existing, cancellationToken)
.ConfigureAwait(false);
}
if (operation.ExpectedVersion is not null)
{
// The client believes it is updating something that does not exist here.
return await ConflictAsync(vault, kind, operation, existing: null, cancellationToken)
.ConfigureAwait(false);
}
var created = kind.Add(database, operation.EntityId, vault.Id);
created.Version = 1;
created.CreatedAtUtc = now;
created.CreatedByUserId = actorUserId;
ApplyFields(kind, created, operation.Payload!, fields, actorUserId, now);
return await RecordAsync(
vault, kind, actorUserId, operation, created, ChangeOperation.Upsert, cancellationToken)
.ConfigureAwait(false);
}
private async Task ApplyDeleteAsync(
Vault vault,
IItemKind kind,
Guid actorUserId,
SyncPushOperation operation,
IVaultItem? existing,
CancellationToken cancellationToken)
{
if (existing is null)
{
return Invalid(operation, "Cannot delete an item that does not exist.");
}
if (existing.DeletedAtUtc is not null)
{
// Already a tombstone. Idempotent rather than an error: a client retrying a delete it
// is unsure about should not have to distinguish these.
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Applied,
existing.Version,
existing.ChangeSequence,
ServerEntity: null,
Detail: null);
}
if (operation.ExpectedVersion is not null && operation.ExpectedVersion != existing.Version)
{
return await ConflictAsync(vault, kind, operation, existing, cancellationToken)
.ConfigureAwait(false);
}
var now = clock.GetUtcNow();
existing.Version++;
existing.DeletedAtUtc = now;
existing.UpdatedAtUtc = now;
existing.UpdatedByUserId = actorUserId;
kind.ClearFieldsOnDelete(existing);
return await RecordAsync(
vault, kind, actorUserId, operation, existing, ChangeOperation.Delete, cancellationToken)
.ConfigureAwait(false);
}
///
/// The ciphertext and the bookkeeping are identical for every item type; only the plaintext columns
/// differ, and those are the type's own business. Splitting it this way is what stops a new item type
/// from having to restate — and possibly misstate — how a payload is stored.
///
private static void ApplyFields(
IItemKind kind,
IVaultItem item,
EncryptedPayload payload,
SyncPlaintextFields fields,
Guid actorUserId,
DateTimeOffset now)
{
item.Payload = payload.Envelope;
item.DataKeyWrap = payload.WrappedDataKey;
item.ContentKeyId = payload.DataKeyId;
item.KeyGeneration = (int)payload.KeyGeneration;
item.PayloadAadVersion = payload.AadVersion;
item.DeletedAtUtc = null;
item.UpdatedAtUtc = now;
item.UpdatedByUserId = actorUserId;
kind.ApplyFields(item, fields);
}
///
/// Rejects a payload missing its data key.
///
///
/// The server cannot read any of these bytes, so this is a structural check and nothing more.
/// It is still worth making: docs/crypto.md §3 requires a per-item data key, the payload's AAD
/// binds , and a row stored without a wrap is a row no
/// client will ever be able to open. Better to refuse it here — where the client is told which
/// operation was wrong — than to store an item that silently reads as corrupt forever.
///
private static bool ValidatePayload(EncryptedPayload payload, out string error)
{
error = string.Empty;
if (payload.Envelope.Length == 0)
{
error = "A payload envelope cannot be empty.";
return false;
}
if (payload.WrappedDataKey.Length == 0)
{
error = "A payload requires its data key, wrapped under the vault key.";
return false;
}
if (payload.DataKeyId == Guid.Empty)
{
error = "A payload requires a data key identifier; it is part of the payload's AAD.";
return false;
}
return true;
}
private async Task RecordAsync(
Vault vault,
IItemKind kind,
Guid actorUserId,
SyncPushOperation operation,
IVaultItem item,
ChangeOperation changeOperation,
CancellationToken cancellationToken)
{
var change = new VaultChange
{
VaultId = vault.Id,
EntityType = kind.ChangeType,
EntityId = item.Id,
Operation = changeOperation,
Revision = item.Version,
ActorUserId = actorUserId,
OccurredAtUtc = clock.GetUtcNow(),
};
database.VaultChanges.Add(change);
// Saved before the denormalised pointer is set, because the sequence is assigned by the
// database on insert.
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
item.ChangeSequence = change.Sequence;
database.SyncOperationReceipts.Add(new SyncOperationReceipt
{
OperationId = operation.OperationId,
VaultId = vault.Id,
AppliedChangeSequence = change.Sequence,
ResultVersion = item.Version,
CreatedAtUtc = clock.GetUtcNow(),
});
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Applied,
item.Version,
change.Sequence,
ServerEntity: null,
Detail: null);
}
private async Task ConflictAsync(
Vault vault,
IItemKind kind,
SyncPushOperation operation,
IVaultItem? existing,
CancellationToken cancellationToken)
{
// The server cannot merge ciphertext, so it hands back its current state and the client
// performs a three-way merge against its retained common ancestor. Never last-writer-wins.
Contracts.SyncChange? serverEntity = null;
if (existing is not null)
{
var hydrated = await HydrateAsync(
vault,
[new VaultChange
{
Sequence = existing.ChangeSequence,
VaultId = existing.VaultId,
EntityType = kind.ChangeType,
EntityId = existing.Id,
Operation = existing.DeletedAtUtc is null
? ChangeOperation.Upsert
: ChangeOperation.Delete,
Revision = existing.Version,
OccurredAtUtc = existing.UpdatedAtUtc,
}],
cancellationToken).ConfigureAwait(false);
serverEntity = hydrated.Count > 0 ? hydrated[0] : null;
}
SyncLog.PushConflict(logger, operation.EntityId, operation.ExpectedVersion, existing?.Version);
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Conflict,
existing?.Version,
existing?.ChangeSequence,
serverEntity,
Detail: null);
}
/// Attaches current row state to change-log entries.
///
///
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
///
///
/// ◆ An entry an item has since moved past is left out. The log keeps every revision, but the
/// table keeps only the latest ciphertext — so hydrating an old entry paired that entry's revision number
/// with the newest payload, and the payload's AAD binds the revision it was sealed at. A client opened it
/// at the old number, failed, and parked its own unsent edit to that item as undecryptable. The later
/// entry is in this page or a later one, since the item's ChangeSequence is that entry's sequence;
/// it carries the same payload under the right number, so nothing is lost by sending it once. Leaving
/// the old entries out also stops a fresh device downloading an item once per edit it ever had. The
/// cursor is computed from the unfiltered page by the caller, so it still moves past them.
///
///
private async Task> HydrateAsync(
Vault vault,
List changes,
CancellationToken cancellationToken)
{
var rows = new Dictionary<(ChangeEntityType Type, Guid Id), IVaultItem>();
var kinds = new Dictionary();
foreach (var group in changes.GroupBy(change => change.EntityType))
{
if (KindForChange(group.Key) is not { } kind)
{
// A change this server wrote under a type it no longer synchronises. Nothing sensible to
// hydrate, and dropping the entry would leave a gap in the client's cursor — so it is
// emitted below with no payload, which a client already handles as a tombstone.
continue;
}
kinds[group.Key] = kind;
var ids = group.Select(change => change.EntityId).Distinct().ToArray();
var loaded = await kind.LoadAsync(database, vault.Id, ids, cancellationToken)
.ConfigureAwait(false);
foreach (var (id, item) in loaded)
{
rows[(group.Key, id)] = item;
}
}
return [.. changes
.Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
|| row.ChangeSequence <= change.Sequence)
.Select(change => Describe(
change,
kinds.GetValueOrDefault(change.EntityType),
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
}
/// Turns one change-log entry and its row into what a client receives.
private static Contracts.SyncChange Describe(VaultChange change, IItemKind? kind, IVaultItem? item)
{
// A delete carries no payload: there is nothing left to decrypt, and shipping the pre-delete
// ciphertext would undermine the point of the tombstone.
var isDelete = change.Operation == ChangeOperation.Delete || item?.DeletedAtUtc is not null;
var opaque = isDelete || item is null;
return new Contracts.SyncChange(
EntityType: kind?.WireType ?? ToWire(change.EntityType),
EntityId: change.EntityId,
Operation: isDelete ? SyncOperation.Delete : SyncOperation.Upsert,
Version: change.Revision,
ChangeSequence: change.Sequence,
Payload: opaque
? null
: new EncryptedPayload(
item!.Payload,
// Non-null for every row a push can create: ValidatePayload refuses an operation
// without them. The columns stay nullable because they are also the seam for M5's
// per-item grants.
item.DataKeyWrap ?? [],
item.ContentKeyId ?? Guid.Empty,
(uint)item.KeyGeneration,
(byte)item.PayloadAadVersion),
PlaintextFields: opaque || kind is null ? null : kind.Hydrate(item!),
UpdatedAt: change.OccurredAtUtc);
}
/// The kind behind a change-log entry, or null for a type this server does not synchronise.
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
///
/// Never below the vault's horizon: collecting a tombstone can take the newest entries with it, and a
/// head reported lower than a cursor the client already holds would rewind it under the horizon and
/// cost a full resync for nothing.
///
private async Task CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken)
{
var head = await database.VaultChanges
.Where(c => c.VaultId == vaultId)
.OrderByDescending(c => c.Sequence)
.Select(c => c.Sequence)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return Math.Max(head, await HorizonAsync(vaultId, cancellationToken).ConfigureAwait(false));
}
/// The vault's horizon as the database holds it now, not as the request loaded it.
private Task HorizonAsync(Guid vaultId, CancellationToken cancellationToken) =>
database.Vaults
.Where(v => v.Id == vaultId)
.Select(v => v.SyncHorizon)
.SingleAsync(cancellationToken);
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
new(
operation.OperationId,
SyncOperationStatus.Invalid,
Version: null,
ChangeSequence: null,
ServerEntity: null,
Detail: detail);
///
/// The two enums are numerically aligned by hand — and they do not even use the same member names for
/// the same value, Host against SshHost — so nothing in the type system defends this cast.
/// EntityTypeAlignmentTests is what defends it; if it fails, changes would start being filed
/// under another item type's name and a pull would hand clients the wrong ciphertext.
///
private static ChangeEntityType ToDomain(SyncEntityType type) => (ChangeEntityType)(int)type;
///
private static SyncEntityType ToWire(ChangeEntityType type) => (SyncEntityType)(int)type;
}