using System.Text.Json; using System.Text.Json.Serialization; using DodoSSH.Client.Domain; namespace DodoSSH.Client.Sync; /// Tuning for a sync pass. /// /// The push batch default is well below the server's own cap. A smaller batch means a conflict is /// discovered and merged sooner, and it bounds how much work one rejected batch delays. /// public sealed record SyncOptions { /// Changes requested per pull. The server clamps this. public int PullPageSize { get; init; } = 500; /// Operations sent per push. Must not exceed the server's advertised maximum. public int MaxOperationsPerPush { get; init; } = 100; /// /// How many push rounds one pass may take. /// /// /// A bound rather than a loop until clear. Each round either applies, parks, or merges and advances /// a version, so it does terminate — but against a vault someone else is writing to continuously it /// could keep finding new conflicts, and a sync pass that never returns is worse than one that stops /// and says so. /// public int MaxPushRounds { get; init; } = 8; /// /// How many times an operation may be dispatched before it is parked for a person to look at. /// public int MaxAttemptsBeforeParking { get; init; } = 5; /// /// How many pages one pull may read. /// /// /// A backstop against a server that keeps saying there is more. At the default page size this is /// half a million changes, well past any real vault, so reaching it means something is wrong rather /// than merely large. /// public int MaxPullPages { get; init; } = 1000; /// The defaults. public static SyncOptions Default { get; } = new(); } /// What one sync pass did. /// The vault. /// Changes received. /// Operations the server accepted. /// Items where local and remote edits were reconciled. /// /// Items someone else deleted while this machine had unpushed edits. The tombstone stands and the local /// content survives under a new id; nothing is discarded. /// /// /// Local deletions dropped because the other side edited the item instead. An edit outlives a removal: /// re-deleting costs a click, and a discarded edit may be irrecoverable. /// /// Operations the server refused, now waiting on a person. /// Items whose payload would not decrypt. /// The generation the server reports for this vault. /// /// True when the server's generation is ahead of the key this client holds, so items cannot be read /// until new grants arrive. /// /// /// Difference between the server's clock and this machine's. Recorded, never acted on — the merge uses /// versions and a retained ancestor, so a skewed clock must not be able to decide which edit wins. /// /// /// True when the push loop hit with work still outstanding. Not /// a failure: the next pass continues from here. /// /// /// True when the server refused this machine's stored position and the whole log was read again from the /// beginning. /// /// Deliberately absent from . Nothing is outstanding and nothing was lost — /// the pass recovered on its own — but it explains a sync that pulled the entire vault on a day nobody /// changed anything, which is otherwise the sort of thing that looks like a fault. /// /// public sealed record SyncReport( Guid VaultId, int Pulled, int Pushed, int Merged, int Resurrected, int DeletesAbandoned, int Parked, int Unreadable, uint ServerKeyGeneration, bool RekeyRequired, long ServerTimeSkewMs, bool RoundsExhausted, bool ResyncedFromStart, int PushedLogEntries = 0, int PulledLogEntries = 0) { /// Whether anything happened that a user should be told about. public bool NeedsAttention => Resurrected > 0 || DeletesAbandoned > 0 || Parked > 0 || Unreadable > 0 || RekeyRequired; /// /// Operations the server accepted that were somebody's own work rather than a log entry. /// /// /// The number a background pass decides whether to speak about. Every user action queues a log entry a /// moment later, so a pass that reported on alone would have something to announce /// after every save — and would overwrite the message the save had just written. Defaulted so that the /// hundreds of existing constructions of this record go on meaning what they meant. /// public int PushedItems => Pushed - PushedLogEntries; /// /// Changes received that were somebody's own work rather than a log entry. /// /// /// The other half of the same rule, and it needs saying because the case is not obvious: a machine pulls /// back the log entries it has just pushed, so a pass immediately after a save reports a pull as well as /// a push, and both are the machine talking about itself. /// public int PulledItems => Pulled - PulledLogEntries; } /// Accumulates a while a pass runs. internal sealed class SyncReportBuilder(Guid vaultId) { internal int Pulled { get; set; } internal int Pushed { get; set; } /// How many of those were log entries rather than the user's own items. internal int PushedLogEntries { get; set; } /// internal int PulledLogEntries { get; set; } internal int Merged { get; set; } internal int Resurrected { get; set; } internal int DeletesAbandoned { get; set; } internal int Parked { get; set; } internal int Unreadable { get; set; } internal uint ServerKeyGeneration { get; set; } internal bool RekeyRequired { get; set; } internal long ServerTimeSkewMs { get; set; } internal bool RoundsExhausted { get; set; } internal bool ResyncedFromStart { get; set; } internal SyncReport Build() => new( vaultId, Pulled, Pushed, Merged, Resurrected, DeletesAbandoned, Parked, Unreadable, ServerKeyGeneration, RekeyRequired, ServerTimeSkewMs, RoundsExhausted, ResyncedFromStart, PushedLogEntries, PulledLogEntries); } /// The record written to the conflict log when a merge had to override something. /// Which field, as a path. /// Whose intent was overridden: Local or Remote. /// The value that survives. /// The value that lost. /// Whether what lost was a deletion rather than a value. public sealed record ConflictDetailEntry( string Field, string DiscardedSide, string? Kept, string? Discarded, bool DiscardedWasRemoval); /// A conflict log entry. /// One line for a person to read. /// Everything the merge overrode. public sealed record ConflictDetail(string Summary, IReadOnlyList Fields); /// /// Reads and writes what a merge discarded, for the conflict log. /// /// /// The bytes crossing into ConflictStore are plaintext vault content and are sealed there under /// the LocalCacheKey. Deliberately its own format rather than the item payload's: this is local /// bookkeeping and is never pushed, so it has no compatibility obligation to any other client. /// /// Reading is public because the whole justification for resolving a conflict automatically is that the /// overridden value gets shown. A codec only the writer could use would make that impossible. /// /// public static class ConflictDetails { internal static byte[] Encode(string summary, IReadOnlyList conflicts) => JsonSerializer.SerializeToUtf8Bytes( new ConflictDetail( summary, [.. conflicts.Select(c => new ConflictDetailEntry( c.Field, c.DiscardedSide.ToString(), c.Kept, c.Discarded, c.DiscardedWasRemoval))]), ConflictJsonContext.Default.ConflictDetail); internal static byte[] Encode(string summary) => Encode(summary, []); /// Reads a detail back, for display. /// /// The detail, or when the record will not parse — which is what an entry /// written before a passphrase change looks like, since its sealed bytes no longer open. /// public static ConflictDetail? TryRead(ReadOnlySpan utf8) { try { return JsonSerializer.Deserialize(utf8, ConflictJsonContext.Default.ConflictDetail); } catch (JsonException) { return null; } } } [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] [JsonSerializable(typeof(ConflictDetail))] internal sealed partial class ConflictJsonContext : JsonSerializerContext;