using System.Runtime.InteropServices; namespace DodoSSH.Client.Domain; /// Which of the two diverging replicas a value came from. public enum MergeSide { /// The edit made on this machine. Local = 0, /// The edit that arrived from the server. Remote = 1, } /// How a single field was resolved. public enum MergeDecision { /// /// Both sides hold the same value — either neither changed it, or both made the identical /// change. Distinguishing those two is not useful: the outcome is the same and no one is /// surprised. /// Agreed = 0, /// Only this machine changed it. TookLocal = 1, /// Only the server side changed it. TookRemote = 2, /// Both changed it, differently. One value survives and the other is reported. Conflicted = 3, } /// The outcome of merging one field. /// The field's type. /// The value to keep. /// How it was resolved. /// /// The value that lost, meaningful only when is /// . Never simply dropped: the caller is expected to record it. /// [StructLayout(LayoutKind.Auto)] public readonly record struct FieldMerge(T Value, MergeDecision Decision, T? Discarded) { /// Whether both sides changed this field to different values. public bool IsConflicted => Decision == MergeDecision.Conflicted; } /// A key whose value both sides changed, or which one side removed while the other edited. /// Key type. /// Value type. /// The key in question. /// The value that survives, or if the key is removed. /// Which replica's intent was overridden. /// /// The value that lost, or when what lost was a removal. /// /// /// True when the overridden intent was to remove the key rather than to set it to a different value. /// [StructLayout(LayoutKind.Auto)] public readonly record struct MapConflict( TKey Key, TValue? Kept, MergeSide DiscardedSide, TValue? Discarded, bool DiscardedWasRemoval); /// The outcome of merging a keyed collection. /// Key type. /// Value type. /// The resulting collection. /// Every key where the two sides disagreed. [StructLayout(LayoutKind.Auto)] public readonly record struct MapMerge( IReadOnlyDictionary Merged, IReadOnlyList> Conflicts) where TKey : notnull; /// /// The merge primitives: resolve a field, or a keyed collection, from a common ancestor and two /// divergent versions. /// /// /// /// The server cannot do any of this — it cannot read a payload, so it cannot merge one. That is why /// a conflicting push comes back with the server's current row rather than being resolved for us, /// and why this code is the last line of defence against losing a credential. /// /// /// Why the remote side wins a genuine clash. It has to be one of them, and it has to be the /// same one on every replica. If each client kept its own value, two clients would resolve the same /// triple in opposite directions, each push would conflict with the other's, and they would ping-pong /// forever without converging. Deferring to the value already on the server converges in one round. /// /// /// The losing value is never discarded silently. Every primitive returns it, the item-level /// merge collects them, and the sync engine writes them to a conflict log the user can act on. This /// is the whole point: a merge that quietly drops the password someone just typed is worse than one /// that refuses to merge at all. /// /// public static class ThreeWayMerge { /// /// Resolves one field. /// /// The value both sides started from. /// This machine's value. /// The server's value. /// Value comparison; defaults to . public static FieldMerge Scalar( T ancestor, T local, T remote, IEqualityComparer? comparer = null) { comparer ??= EqualityComparer.Default; // Checked first, so two people making the identical edit is agreement rather than a // conflict they have to be bothered about. if (comparer.Equals(local, remote)) { return new FieldMerge(local, MergeDecision.Agreed, default); } if (comparer.Equals(local, ancestor)) { return new FieldMerge(remote, MergeDecision.TookRemote, default); } if (comparer.Equals(remote, ancestor)) { return new FieldMerge(local, MergeDecision.TookLocal, default); } return new FieldMerge(remote, MergeDecision.Conflicted, local); } /// /// Resolves a keyed collection key by key. /// /// /// /// Per-key rather than whole-collection, which is the difference between two people each adding /// a directive and both keeping it, versus one of them losing theirs to a conflict. That is the /// single most visible benefit of a field-level merge over last-writer-wins. /// /// /// An edit beats a removal. Where one side deleted a key and the other changed its value, /// the value survives and the removal is reported. The asymmetry is deliberate and it is not a /// preference: re-applying a removal costs one click, while a discarded value may be the only /// copy of something the user cannot reconstruct. /// /// /// The state both sides started from. /// This machine's state. /// The server's state. /// Defines key identity. /// Value comparison; defaults to . public static MapMerge Map( IReadOnlyDictionary ancestor, IReadOnlyDictionary local, IReadOnlyDictionary remote, IEqualityComparer keyComparer, IEqualityComparer? valueComparer = null) where TKey : notnull { ArgumentNullException.ThrowIfNull(ancestor); ArgumentNullException.ThrowIfNull(local); ArgumentNullException.ThrowIfNull(remote); ArgumentNullException.ThrowIfNull(keyComparer); valueComparer ??= EqualityComparer.Default; var merged = new Dictionary(keyComparer); var conflicts = new List>(); foreach (var key in UnionOfKeys(ancestor, local, remote, keyComparer)) { var a = Slot.For(ancestor, key); var l = Slot.For(local, key); var r = Slot.For(remote, key); var resolved = ResolveKey(key, a, l, r, valueComparer, conflicts); if (resolved.Present) { merged[key] = resolved.Value!; } } return new MapMerge(merged, conflicts); } /// One key's state on one replica: present with a value, or absent. [StructLayout(LayoutKind.Auto)] private readonly record struct Slot(bool Present, TValue? Value) { internal bool Matches(in Slot other, IEqualityComparer comparer) => Present == other.Present && (!Present || comparer.Equals(Value!, other.Value!)); } private static class Slot { internal static Slot For( IReadOnlyDictionary source, TKey key) => source.TryGetValue(key, out var value) ? new Slot(true, value) : new Slot(false, default); } private static Slot ResolveKey( TKey key, in Slot ancestor, in Slot local, in Slot remote, IEqualityComparer valueComparer, List> conflicts) { if (local.Matches(remote, valueComparer)) { return local; } if (local.Matches(ancestor, valueComparer)) { return remote; } if (remote.Matches(ancestor, valueComparer)) { return local; } // Both sides moved. Prefer whichever still holds a value, so an edit outlives a removal; // where both hold one, defer to the server so every replica converges the same way. var winner = remote.Present ? remote : local; var loserSide = remote.Present ? MergeSide.Local : MergeSide.Remote; var loser = remote.Present ? local : remote; conflicts.Add(new MapConflict( key, winner.Value, loserSide, loser.Present ? loser.Value : default, DiscardedWasRemoval: !loser.Present)); return winner; } private static IEnumerable UnionOfKeys( IReadOnlyDictionary ancestor, IReadOnlyDictionary local, IReadOnlyDictionary remote, IEqualityComparer keyComparer) where TKey : notnull { var seen = new HashSet(keyComparer); foreach (var key in ancestor.Keys.Concat(local.Keys).Concat(remote.Keys)) { if (seen.Add(key)) { yield return key; } } } }