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.
- CloudKit docs for SQLiteData — Official documentation for CloudKit synchronization in SQLiteData
- structuredpath/sqlite-data-sync-notes — Notes on SQLiteData’s current CloudKit sync behavior and a conceptual model for built-in conflict resolution
- structuredpath/sqlite-data — Fork of SQLiteData containing the in-progress implementation
- Forked — Library for merging concurrent data changes with a Git-like model (see
Mergeableprotocol) - Ensembles 3 — Sync framework for Core Data and SwiftData (see Conflict resolution and SwiftData integration)
- 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.
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.
| 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 | ➡️ 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 | ➡️ Superseded by #508 | |
| 2026‑03‑03 | PR | #411 | Set CKRecord.modificationDate in MockCloudDatabase |
🟠 Awaiting review |
| 2026‑03‑04 | PR | #412 | 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 |
- 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 andpr1)
- Ensure timestamp update for conflicts where both sides remove a value (see
sameFieldRemoval_conflictOnSend_clientNewertest) - Add test coverage for asset-backed fields in the record decoding path
- Align
CKRecord’s print representation withRowVersion(@123instead of 📅)
- 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
.serverRecordChangederror. 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@Tabletypes, which the fork achieves through a synthetic SQLSELECTexecuted on the database. Is this approach acceptable? - Macro infrastructure. Design and implementation of the
@SyncedTable/@SyncedFieldmacros for configuring the sync engine. - Built-in merge policies. Which merge policies should the library ship with beyond
.latest? The prototype includes.counterand.setas demonstrations.