From 08d2049112f0ee0dad42208d4f1e67d6440cf482 Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Mon, 23 Jun 2025 12:17:08 -0700 Subject: [PATCH 1/2] (#358) Default Offline conflict resolver. --- .../Offline/DatasyncOfflineOptionsBuilder.cs | 17 ++++- .../Offline/Models/OfflineOptions.cs | 9 ++- .../Offline/ConflictResolver_Tests.cs | 65 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs index 6677022..519bc81 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs @@ -14,6 +14,7 @@ namespace CommunityToolkit.Datasync.Client.Offline; public class DatasyncOfflineOptionsBuilder { internal IHttpClientFactory? _httpClientFactory; + internal IConflictResolver? _defaultConflictResolver; internal readonly Dictionary _entities; /// @@ -78,6 +79,19 @@ public DatasyncOfflineOptionsBuilder UseHttpClientOptions(HttpClientOptions clie return this; } + /// + /// Sets the default conflict resolver to use for all entities that do not have a specific + /// conflict resolver set. + /// + /// The default conflict resolver. + /// The current builder for chaining. + public DatasyncOfflineOptionsBuilder UseDefaultConflictResolver(IConflictResolver conflictResolver) + { + ArgumentNullException.ThrowIfNull(conflictResolver); + this._defaultConflictResolver = conflictResolver; + return this; + } + /// /// Configures the specified entity type for offline operations. /// @@ -133,7 +147,8 @@ internal OfflineOptions Build() OfflineOptions result = new() { - HttpClientFactory = this._httpClientFactory + HttpClientFactory = this._httpClientFactory, + DefaultConflictResolver = this._defaultConflictResolver }; foreach (EntityOfflineOptions entity in this._entities.Values) diff --git a/src/CommunityToolkit.Datasync.Client/Offline/Models/OfflineOptions.cs b/src/CommunityToolkit.Datasync.Client/Offline/Models/OfflineOptions.cs index 997e9ce..ba722f6 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/Models/OfflineOptions.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/Models/OfflineOptions.cs @@ -20,6 +20,11 @@ internal class OfflineOptions() /// public required IHttpClientFactory HttpClientFactory { get; init; } + /// + /// The default to use for this request. + /// + public IConflictResolver? DefaultConflictResolver { get; set; } + /// /// Adds an entity to the mapping of options. /// @@ -50,7 +55,7 @@ public EntityDatasyncOptions GetOptions(Type entityType) { return new() { - ConflictResolver = options.ConflictResolver, + ConflictResolver = options.ConflictResolver ?? DefaultConflictResolver, Endpoint = options.Endpoint, HttpClient = HttpClientFactory.CreateClient(options.ClientName), QueryDescription = options.QueryDescription ?? new QueryDescription() @@ -60,7 +65,7 @@ public EntityDatasyncOptions GetOptions(Type entityType) { return new() { - ConflictResolver = null, + ConflictResolver = DefaultConflictResolver, Endpoint = new Uri($"tables/{entityType.Name.ToLowerInvariant()}", UriKind.Relative), HttpClient = HttpClientFactory.CreateClient(), QueryDescription = new QueryDescription() diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/ConflictResolver_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/ConflictResolver_Tests.cs index f25f930..4a812df 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/ConflictResolver_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/ConflictResolver_Tests.cs @@ -165,6 +165,71 @@ public async Task GenericConflictResolver_BothNull_ShouldReturnDefault() #region Integration with OperationsQueueManager Tests + [Fact] + public async Task PushAsync_WithDefaultClientWinsResolver_ShouldResolveConflictAndRetry() + { + // Arrange + var context = CreateContext(); + + // Configure context to use client wins resolver + context.Configurator = builder => + { + builder.UseDefaultConflictResolver(new ClientWinsConflictResolver()); + builder.Entity(c => + { + c.ClientName = "movies"; + c.Endpoint = new Uri("/tables/movies", UriKind.Relative); + }); + }; + + // Create a client movie and save it to generate operation + var clientMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = Guid.NewGuid().ToString("N"), + Title = "Client Title" + }; + context.Movies.Add(clientMovie); + context.SaveChanges(); + + // Setup response for conflict followed by success + var serverMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Server Title", + UpdatedAt = DateTimeOffset.UtcNow, + Version = Guid.NewGuid().ToString() + }; + string serverJson = DatasyncSerializer.Serialize(serverMovie); + + // First response is a conflict + context.Handler.AddResponseContent(serverJson, HttpStatusCode.Conflict); + + // Second response (after resolution) is success + var finalMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Client Title", // This should match the client version after resolution + UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1), + Version = Guid.NewGuid().ToString() + }; + string finalJson = DatasyncSerializer.Serialize(finalMovie); + context.Handler.AddResponseContent(finalJson, HttpStatusCode.OK); + + // Act + var result = await context.QueueManager.PushAsync([typeof(ClientMovie)], new PushOptions()); + + // Assert + result.IsSuccessful.Should().BeTrue(); + result.CompletedOperations.Should().Be(1); + result.FailedRequests.Should().BeEmpty(); + + // Verify the database has the right value + var savedMovie = context.Movies.Find(clientMovie.Id); + savedMovie.Should().NotBeNull(); + savedMovie!.Title.Should().Be("Client Title"); + savedMovie.Version.Should().Be(finalMovie.Version); + } + [Fact] public async Task PushAsync_WithClientWinsResolver_ShouldResolveConflictAndRetry() { From 6c6b1e33c1f7dab8113da05cea130411e3b6f79f Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Mon, 23 Jun 2025 13:49:19 -0700 Subject: [PATCH 2/2] (#358) Updated documentation --- docs/in-depth/client/index.md | 76 ++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/docs/in-depth/client/index.md b/docs/in-depth/client/index.md index 2693138..8c7d207 100644 --- a/docs/in-depth/client/index.md +++ b/docs/in-depth/client/index.md @@ -117,6 +117,29 @@ This example shows all of the options that can be configured for an entity: * The `Endpoint` can be relative or absolute. If relative, it is relative to the `BaseAddress` of the `HttpClient` that is used. * The `Query` limits which entities are requested from the remote service. +### Configuring automatic conflict resolution + +By default, the library does not do conflict resolution automatically. You can set an automated conflict resolver by writing an `IConflictResolver` or `IConflictResolver` implementation. The library provides two by default: + +* `ClientWinsConflictResolver` will force-write the client version to the server. +* `ServerWinsConflictResolver` will replace the client version with the server version. + +You can set the conflict resolver in two ways - per-entity or as a fallback default: + +```csharp +protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder builder) +{ + // A fallback default for cases when you did not set one per entity + builder.UseDefaultConflictResolver(new ClientWinsConflictResolver()); + + // Set a specific conflict resolver for an entity. + builder.Entity(cfg => { + cfg.ConflictResolver = new ServerWinsConflictResolver(); + // Along with any other settings you want to use + }) +} +``` + ## Local only entities You can specify that a dataset is not to be synchronized by using the `[DoNotSynchronize]` attribute: @@ -165,10 +188,61 @@ When the push result is complete, the `PushResult` is returned. This has the fo * `CompletedOperations` - the number of operations that were completed successfully. * `IsSuccessful` - a boolean to indicate that the push was completed with no errors. -* `FailedRequests` - a `Dictionary` that indicates which requests failed. +* `FailedRequests` - a `Dictionary` that indicates which requests failed. In addition, the operations queue is updated. Completed operations are removed and failed operations are marked as failed. You can use the `FailedRequests` property to see the exact error that was returned by the service. +### Conflict resolution + +When a conflict resolver is configured, that will be used before a queued change is marked as failed. In the case of a failed request, you can process the failed requests as follows: + +```csharp +foreach (var failedRequest in result.FailedRequests) +{ + var operationId = failedRequest.Key; + var serviceResponse = failedRequest.Value; + + DatasyncOperation operation = context.DatasyncOperationsQueue.Single(x => x.Id == operationId); + // operation.EntityType is the type of entity being transferred + // operation.Item is the JSON-serialized client-side entity + // operation.EntityVersion is the version of the entity that should be overwritten + // serviceResponse.ContentStream is the JSON-serialized server-side entity +} +``` + +Handling conflicts is complex and involves modifying the queue entity and/or client-side entity to match requirements. Use conflict resolvers in preference of these manual techniques. A conflict resolver is an implementation of `IConflictResolver` or `IConflictResolver` that is attached to the push operation. The main method is `ResolveConflictAsync()`. For example, let's look at the "client-wins" conflict resolver: + +```csharp +public class ClientWinsConflictResolver : IConflictResolver +{ + /// + public async Task ResolveConflictAsync(object? clientObject, object? serverObject, CancellationToken cancellationToken = default) + { + return new ConflictResolution { Result = ConflictResolutionResult.Client, Entity = clientObject }; + } +} +``` + +The `IConflictResolver` is the same as `IConflictResolver` with the notable exception that the `clientObject` and `serverObject` are typed instead of objects. The `ConflictResolution` result model consists of two parts: + +* `Result` is either `ConflictResolutionResult.Client` (indicating that the client wins and the server entity should be overwritten) or `ConflictResolutionResult.Server` (indicating that the server wins and the client entity should be overwritten). +* `Entity` is the entity that should be written. + +To provide another example, let's say you want to allow updates from the client for all columns except for a `Title` column. You can do this as follows: + +```csharp +public class CustomConflictResolver : IConflictResolver +{ + public async Task ResolverConflictAsync(Movie? clientObject, Movie? serverObject, CancellationToken cancellationToken = default) + { + clientObject.Movie = serverObject.Movie; + return new ConflictResolution { Result = ConflictResolutionResult.Client, Entity = clientObject }; + } +} +``` + +Here, we copy the server value of the movie title to the client before returning so that the title is preserved. + ## Pulling data from the service As with push operations, there are many ways of pulling data from the service. For most situations, you can specify a single filter when configuring the datasync service in `OnDatasyncInitialization` and then use one of the following methods: