Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 

Repository files navigation

Customizable Conflict Resolution for SQLiteData

This repository tracks the effort to support customizable conflict resolution for CloudKit sync in SQLiteData. The work started in the umbrella discussion #272 and has since unfolded across issues, pull requests, and design conversations in the upstream repository, so the discussion no longer reflects the latest state. This page serves as a hub for the effort. It outlines the motivation and the proposed approach, and it collects the current status of all related work, the queue of planned pull requests, and the open design topics.

Resources

Motivation

  • Preventing silent data loss. The built-in “field-wise last edit wins” strategy can discard valid changes when devices concurrently edit the same field of the same row, especially when multiple participants collaborate on shared records. Custom resolution makes it possible to preserve changes from both sides instead of letting one win purely by timestamp.
  • Domain-specific merge semantics. Counters should sum their deltas, sets should combine additions and removals from both sides, and complex fields could even use embedded CRDTs for merging. Naive “last edit wins” cannot express such semantics.
  • Extracting the built-in strategy. The built-in strategy is deeply entangled with the sync engine internals and suffers from many subtle issues. Extracting it behind an explicit API makes the merge logic easier to reason about and the issues easier to address.
  • Sensible default, ergonomic customization. The “field-wise last edit wins” strategy remains the default and requires no configuration. Tables that need stronger, domain-aware merging opt in by declaring per-field merge policies. Ideally via a macro-based API, keeping the customization declarative and lightweight.

Approach

The conflict resolution is reworked into an explicit three-way merge model. A conflict is detected at the row level and resolved by merging the ancestor, client, and server versions of the row field by field. Unchanged fields and one-sided edits resolve trivially, and a merge policy is consulted only when both sides diverge from the ancestor. The model is described in the conceptual document, prototyped in #370, and implemented in the fork.

Compared to the current behavior, this changes the following.

  • First-class conflicts. Conflicts are detected in both the conflict-on-send and conflict-on-fetch scenarios. Currently, conflicts are never detected as such, and every incoming server record is merged through the same per-field timestamp checks.
  • Confirmed server state. The last-known server record only ever contains confirmed server state, making it suitable as the merge ancestor. Currently, it is eagerly updated with in-flight records before the server confirms them.
  • No-ancestor conflicts. A conflict can also arise when no last-known server record is available, leaving only the client and server versions. Such conflicts are handled explicitly through a two-way merge, with reconciliation as the proposed term. Currently, they fall through the regular merge logic.

The work is meant to be split into two stages:

  • Stage 1. Expresses the built-in “field-wise last edit wins” strategy through the three-way merge model without changing the public API. It makes the semantics of the current behavior precise and fixes correctness issues in both the implementation and the mock testing infrastructure.
  • Stage 2. Opens the merge logic up for customization, letting tables declare per-field merge policies via a macro-based API alongside a set of built-in policies shipped with the library.

Activity

Date Type Ref Title/Description Status
2025‑10‑23 Discussion #272 Customizable conflict resolution for CloudKit sync (umbrella discussion) 🔄 Ongoing
2025‑10‑24 Issue #273 Date equality mismatch due to millisecond rounding differences between SQLite and CloudKit (converted to discussion as #wontfix) ✅ Fixed in #387
2025‑10‑24 Issue #274 Data divergence between devices after concurrent offline edits 🟠 Open
2025‑11‑30 Issue #315 CloudKit bug with non-atomic saves behaving as atomic ✅ Workaround implemented
2025‑12‑09 Comment #244 Per-field macros in table types for sync engine configuration (encryption) ➡️ See #352
2025‑12‑22 Comment #336 Reaction to PR’d support for non-encrypted fields via SyncEngine.init ➡️ See #352
2026‑01‑01 Discussion #352 Macro-based CloudKit configuration via @SyncedTable/@SyncedField ✅ API confirmed
2026‑01‑02 PR #353 Improve MergeConflictTests coverage and naming ➡️ Superseded by #414
2026‑01‑02 Issue #354 Violation of “last edit wins” conflict resolution strategy 🟠 Open
2026‑01‑05 Issue #356 Inconsistent conflict resolution between production and tests due to unpopulated CKRecord.modificationDate in mocks 🟠 To be fixed by #411
2026‑01‑08 Slack Post n/a Update on my work on customizable conflict resolution ✅ Answered
2026‑01‑13 PR #370 Three-way merge model for built-in conflict resolution for CloudKit sync 📝 Draft
2026‑01‑28 Comment #386 Role of last-known server record & explanation of conflict-on-fetch scenario ✅ Next step identified
2026‑02‑27 Slack Post Screenshot Discussion about timeline and plans for SQLiteData in 2026 ✅ Timeline outlined, call offered
2026‑03‑03 PR #410 Fix metadatabase crash in tests on macOS 15 ➡️ Superseded by #508
2026‑03‑03 PR #411 Set CKRecord.modificationDate in MockCloudDatabase 🟠 Awaiting review
2026‑03‑04 PR #412 Fix MockCloudDatabase record mutation in modify callback ➡️ Superseded by #507
2026‑03‑05 PR #414 Overhaul of merge conflict tests 📝 Draft, depends on #411
2026‑05‑19 Fork PR #1 Reconciliation for no-ancestor case, contributed by Drew McCormack as a discussion PR on my fork ✅ Ideas adopted
2026‑07‑23 Comment #506 Observations on third-party fix for sync divergence when userModificationTime stamps tie ✅ OP agreed with more structured approach
2026‑07‑23 PR #507 Return copied CKRecords when modifying MockCloudDatabase (Brandon’s replacement of #412) ✅ Merged
2026‑07‑24 PR #508 Fix in-memory metadatabase creation in defaultMetadatabase (reframes #410) 🟠 Awaiting merge
2026‑07‑24 PR #510 Deliver latest record state in ModifyRecordsCallback test helper ✅ Merged

Tasks

Next Steps

  • Await merge of #508
  • Await review of #411
  • Submit PR for mocking CKRecord.recordChangeTag (pr3, depends on #411)
  • Finalize #414 for review
  • Submit PR for reconciliation aka two-way merge test coverage (pr1, depends on #414)
  • Submit PR fixing conflicts favoring the client over a newer server, addressing #354 (pr2, depends on #414)
  • Submit PR removing the last-known server record update from the send path (pr4, depends on #414 and pr1)

Backlog

  • Ensure timestamp update for conflicts where both sides remove a value (see sameFieldRemoval_conflictOnSend_clientNewer test)
  • Add test coverage for asset-backed fields in the record decoding path
  • Align CKRecord’s print representation with RowVersion (@123 instead of 📅)

Open Topics

  • Conflict-on-fetch handling. A conflict-on-send arises when the client sends a record that has changed on the server in the meantime and is surfaced through the .serverRecordChanged error. A conflict-on-fetch arises when a newer server record is fetched while the local row holds unsynced changes. Agreement is needed on treating the latter as a first-class conflict, detected through custom ancestor tracking, since the error parameters only cover the send case.
  • Role of the last-known server record. Does it represent the last confirmed server state, or the most up-to-date record we have, including before we’ve even sent the record to iCloud? Should it serve as the ancestor in a three-way merge? Can we drop the eager store of in-flight records?
  • No-ancestor case. A conflict can arise without a last-known server record, for example when two devices create a row with the same ID. The open questions are how this case should be handled and whether it should be called reconciliation.
  • Timestamp management for opted-out tables. Should per-field modification timestamps still be maintained and synced for tables that use a custom strategy instead of the default “field-wise last edit wins”? Keeping them would allow switching back to the default strategy later and exposing timestamps to custom resolvers.
  • Record decoding. Custom resolvers need CKRecords decoded into @Table types, which the fork achieves through a synthetic SQL SELECT executed on the database. Is this approach acceptable?
  • Macro infrastructure. Design and implementation of the @SyncedTable/@SyncedField macros for configuring the sync engine.
  • Built-in merge policies. Which merge policies should the library ship with beyond .latest? The prototype includes .counter and .set as demonstrations.

About

Tracker for supporting customizable conflict resolution in SQLiteData

Topics

Resources

Stars

Watchers

Forks

Contributors