Skip to content
Introducing AI Conversations: Natural Language Interaction for Your Apps! Learn More

Conflict Resolution

When the server returns 409 Conflict or 412 Precondition Failed for an outbox operation, Shiny.Data.Sync consults the endpoint’s DefaultConflictPolicy to decide what to do.

opts.RegisterEndpoint<Project>(url, ep =>
{
ep.DefaultConflictPolicy = ConflictPolicy.AskDelegate; // default
});
PolicyBehavior
AskDelegate (default)Calls IDataSyncDelegate.OnConflict(op, remotePayload).
ServerWinsDrops the local op, dispatches the remote payload through OnReceived as an Update.
ClientWinsRe-queues the local op as-is. The server should ultimately accept it (e.g. drop the ETag check).

OnConflict receives the local SyncOperation and the current remote payload as the server returned it. Decide what to apply by returning a ConflictResolution:

public Task<ConflictResolution> OnConflict(SyncOperation op, string remotePayload)
{
// Discard the local op and accept the remote state. Same as ConflictPolicy.ServerWins.
if (alwaysTrustServer)
return Task.FromResult(ConflictResolution.AcceptRemote);
// Force the local op through. Same as ConflictPolicy.ClientWins.
if (op.Verb == SyncVerb.Delete)
return Task.FromResult(ConflictResolution.KeepLocal);
// Merge: parse both sides, build a combined payload, retry with that.
var local = JsonSerializer.Deserialize<Project>(op.Payload!, AppJsonContext.Default.Project)!;
var remote = JsonSerializer.Deserialize<Project>(remotePayload, AppJsonContext.Default.Project)!;
var merged = local with
{
Title = remote.Title, // server-wins for title
Tags = local.Tags.Union(remote.Tags).ToArray(), // union of tags
UpdatedAt = DateTimeOffset.UtcNow
};
var mergedJson = JsonSerializer.Serialize(merged, AppJsonContext.Default.Project);
return Task.FromResult(ConflictResolution.UseMerged(mergedJson));
}

ConflictResolution factory methods:

FactoryResult
ConflictResolution.AcceptRemoteSame as ServerWins — the remote payload is dispatched as an Update and the local op is removed.
ConflictResolution.KeepLocalRe-queues the local op as-is.
ConflictResolution.UseMerged(string mergedPayload)Replaces the op’s Payload with mergedPayload and re-queues.

Conflict moments fire on the unified Activity stream:

sync.Activity += (s, evt) =>
{
if (evt.Type == SyncEventType.OutboxConflict)
Log.Warn($"{evt.EndpointKey} conflict for {evt.Operation?.EntityIdentifier} (HTTP {evt.StatusCode})");
};

The typed UpdateReceived event also fires the operation as SyncOperationState.ConflictPending so a status bar can flag it.

SituationRecommended policy
Reference data the user can’t edit on the clientServerWins — the server is the source of truth.
Mobile-first workflow where the user expects their edit to winClientWins (or KeepLocal from the delegate).
Real conflicts (two editors, both with local changes)AskDelegate with a merge implementation.
You can’t merge but want to log and skipAskDelegate returning AcceptRemote.

Shiny.Data.Sync does not attach If-Match headers automatically. If your server uses optimistic concurrency, attach the ETag either:

  • in the per-endpoint OnBeforeSend hook (read it off the op’s payload or your local store), or
  • in a global ISyncInterceptor.BeforePush for cross-cutting precondition handling.