Skip to content

Conversation

@lukaskubanek
Copy link
Contributor

@lukaskubanek lukaskubanek commented Jan 13, 2026

This PR explores how the current conflict resolution based on the “field-wise last edit wins” strategy can be expressed as a proper three-way merge model, as described in this document. It is the first step towards a broader effort to support customizable conflict resolution, originally pitched in #272.

With minor exceptions, this PR focuses on Stage 1 with the goal of refining the existing conflict resolution behavior into a clearer and more precise implementation and fixing a few correctness issues along the way. No public API changes are introduced and the current behavior remains effectively the same.

The prototype also lays the groundwork for Stage 2, where user-defined per-field merge policies could be added with minimal changes to the internals. The most promising direction here seems to be a declarative configuration via @SyncedTable / @SyncedField (as discussed in #352), since macros can express per-field policies ergonomically and exhaustively. This would, however, require some additional infrastructure work around macro expansion.

The prototype is not yet integrated into the CloudKit sync pipeline. It is intentionally kept in a standalone file with tests to validate the model and discuss details before further integration work.

Scope of this PR

This PR extracts conflict resolution into a small, self-contained model that performs a proper three-way merge between ancestor, client, and server versions of a row.

The prototype introduces a few supporting types to model conflicts explicitly:

  • MergeConflict<Table> bundles the ancestor, client, and server row versions for three-way merge.
  • RowVersion<Table> wraps a decoded row together with per-field modification timestamps.
  • FieldVersion<Value> represents a single field’s value paired with its modification timestamp.
  • FieldMergePolicy<Value> provides an extensible mechanism for merging conflicting field values.

When a conflict is detected, a MergeConflict is constructed with three concrete row versions:

  • ancestor: Decoded from the last-known server record represented as CKRecord
  • client: Created from the current database row, the row-level userModificationTime, and the ancestor version (to obtain modification timestamps for unchanged fields)
  • server: Decoded from the incoming server record represented as CKRecord

The choice to use value types is primarily for ease of reasoning and testing. It also leaves open the possibility of making some of these types public in Stage 2, so that users could resolve conflicts against Swift types rather than raw record data. Supporting custom per-field merge policies would at minimum require exposing FieldVersion and FieldMergePolicy, and potentially RowVersion as well (see section on custom merge method below).

Since table types cannot currently be constructed dynamically (other than via T.init(decoder:) & QueryDecoder), the prototype decodes rows from CKRecord instances by performing a SQL SELECT query with literal values, leveraging the existing decoding infrastructure.

When a MergeConflict is available, resolution proceeds by iterating over all fields and performing equality checks. Equality is determined via QueryBinding, so field values do not need to conform to Equatable. Trivial cases such as no change or one-sided change are short-circuited. The field merge policy is consulted only when both the client and server diverge from the ancestor.

The prototype comes with an abstraction for per-field merge policies and includes three concrete implementations:

  • .latest: Last edit wins, favoring the edited value with the newest modification timestamp.
  • .counter: Combines independent increments and decrements in integer fields from both edited sides.
  • .set: Merges set fields by preserving elements not deleted on either side and adding any new elements.

In Stage 1, only .latest is intended to be used, as it matches the current behavior in the library (but with clearer semantics). In particular, the merge logic avoids “reviving” ancestor values during conflict resolution, since .latest only compares edited client and server versions.

The additional policies serve primarily to show how the abstraction generalizes and what Stage 2 could unlock. All policies are exercised in the tests for the Post type, where .latest, .counter, and .set are applied to different fields.

After resolving all fields, the merged values must be written back to the database. The prototype does this by generating an UPDATE statement that writes the merged field values directly into the existing row. No upsert is needed here, as a conflicting row is guaranteed to exist in the database.

Earlier drafts explored producing a full Table instance and then generating an UPDATE statement from it, but that path does not seem viable in the current design (see section on custom merge methods below).

For tests, there is a helper method that performs the whole roundtrip of inserting the client row, applying the merge update, fetching the resulting row, and asserting on its values.

Integration

As mentioned above, the prototype isn’t wired to the CloudKit sync pipeline yet. If this model were to be integrated in Stage 1, it would mean expressing the existing “last edit wins” semantics through the three-way merge model. Concretely, this would require a few targeted changes in the conflict handling paths:

  • Align conflict resolution behavior between production and test environments per #356 to form a consistent baseline for the three-way merge model.
  • Ensure the last-known server record contains only confirmed server state and is never polluted with pending local changes, making it suitable to serve as the ancestor in a three-way merge. More on that in this document.
  • Detect conflicts at the record level for both conflict-on-send and conflict-on-fetch scenarios, and handle them separately from non-conflict upserts, which should propagate server state directly without invoking merge logic. More on conflict detection in this comment.
  • Invoke the .latest field merge policy only for fields that diverge from the ancestor on both sides. One-sided edits and unchanged fields should bypass the policy.
  • Consequently, timestamp comparisons are only consulted for conflicting fields, and are skipped entirely for non-conflicting fields as well as regular upserts.

For Stage 2, where users could annotate individual fields with merge policies, a few additional pieces would be needed:

  • Resolve the transition toward CloudKit-aware macros @SyncedTable / @SyncedField so that per-field merge policies can be specified declaratively. More on this in #352.
  • Ensure the specified per-field merge policies are surfaced to the conflict resolution logic. This could take the form of a lookup table of merge policies for each field or a generated merge function. See next section for more on that.
  • Finalize the set of built-in merge policies the library ships with.

Merge method returning T?

While ideating on the API design initially, the motivating idea was to express conflict resolution as a method that receives a MergeConflict containing the three row versions and returns a merged row instance of T (or T.Draft) to be written back to the database.

This approach turned out not to be viable in Stage 1, as there does not seem to be a way to dynamically construct a row instance by inspecting its columns and assigning values to fields, other than going through a database statement and the T.init(decoder:) initializer (as seen in CKRecord decoding)1. For this reason, the prototype resolves conflicts by generating an UPDATE statement that writes merged field values directly to the existing row, without constructing a new row instance.

In Stage 2, with macros allowing users to specify per-field merge policies, the current approach of producing an UPDATE statement could be preserved. In that design, table types would expose the specified merge policies so that the conflict resolution logic can apply them field-by-field.

Alternatively, the macro could generate a merge function in its expansion, iterating over all fields, computing the merged values, and constructing a new row instance. To fully deliver on the original motivation, the library could even allow users to override this with a custom declaration (e.g. via a CustomMergeConflictResolvable protocol).

This direction hasn’t been explored in depth, but opening up a custom merge function comes with some downsides. It competes with macro-defined merge policies and introduces opportunities to omit fields or fall back to default values by accident. The macro-generated path therefore seems more promising.

The only use case for a custom merge function that comes to mind is when multiple fields need to be considered together, e.g. startDate and endDate where endDate must always be greater than or equal to startDate. Such cases can be addressed by (a) grouping the data into a single structure and serializing it as JSON, or (b) installing a trigger that enforces the invariant before updating the row. Both approaches apply more generally, not only in the context of conflict resolution.

It is not entirely clear where Stage 2 should ultimately land, but detecting conflicts at the row level while allowing the user to merge at the field level seems like a reasonable and pragmatic approach.

Open topics

  • Asset handling in the CKRecordRowVersion decoding path. Asset-backed fields are routed through the prototype’s decoding path but are not exercised in tests yet.
  • Conflicts without an ancestor. There are cases where no last-known server record is available and both client and server diverge, which becomes a two-way merge problem (reconciliation or salvaging). For Stage 1, the logic is simple, but for Stage 2 it is unclear whether there should be a dedicated hook in the FieldMergePolicy API for such cases.

Footnotes

  1. Updating values on an existing instance, e.g. the client row, is not viable either, as this would require WritableKeyPaths. It is also perfectly fine for a conflicting field to be represented as a let property in Swift, since the value in the database can still be mutated regardless of mutability in the Swift type.

@lukaskubanek lukaskubanek changed the title Three-way merge model for built-in conflict resolution for CloudKit syncPrototype conflict resolution Three-way merge model for built-in conflict resolution for CloudKit sync Jan 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant