Skip to content

Overhaul of merge conflict tests - #414

Draft
lukaskubanek wants to merge 26 commits into
pointfreeco:mainfrom
structuredpath:merge-conflict-tests
Draft

lukaskubanek wants to merge 26 commits into
pointfreeco:mainfrom
structuredpath:merge-conflict-tests

Conversation

@lukaskubanek

@lukaskubanek lukaskubanek commented Mar 5, 2026 •

Copy link
Copy Markdown
Contributor

Warning

This PR depends on #507, #508 and #510, which are merged, and on #573, which is still open. It is stacked on #573 and includes its commits. The isolated diff is available here. The branch will be rebased once #573 is merged.

Summary

This PR replaces the existing merge conflict tests in MergeConflictTests.swift with a new test suite that covers a broader set of scenarios across multiple dimensions:

  • different fields / same fields
  • client newer / server newer / equal timestamps
  • conflict on send / conflict on fetch
  • ordering on send: fetch before retry / retry before fetch

The new tests expose two real bugs in the current merge conflict resolution logic. These are temporarily marked with withKnownIssue, as the main goal of this PR is to document the current behavior and form a baseline for future work on configurable conflict resolution.

Improvements

  • A simpler Post table type (standalone, no parent entity required) instead of Reminder, which depends on RemindersList.
  • Snapshots assert on container.privateCloudDatabase directly, reducing boilerplate compared to the full container, as the shared database isn’t interesting for these tests.
  • Conflict scenarios use absolute timestamps (e.g. t=30, t=60) for client modification instead of relative increments. This is now aligned with how CKRecords are being treated.
  • Test steps are now annotated and ordered to match real-world event sequences. This is most notable with the client and server edits, which used to not be ordered chronologically.
  • Local database state is now checked for convergence with the CloudKit state after each scenario resolves.

Bugs Identified

The new tests reveal the following issues in the current implementation:

  1. The client always wins on same-field conflicts. Even when the server record has a newer modification timestamp, the client value is retained. This violates the “last edit wins” conflict resolution strategy as previously reported in #354.
  2. Converging edits keep the older timestamp. When client and server independently arrive at the same value, including both removing it, the field keeps the server’s older stamp even when the client’s edit is newer, because the engine only restamps fields whose value changed.

Overview

# Test Name Client Edits Server Edits Expected Result Actual Result Correct? Replaces
1 differentFieldsChange_conflictOnSend_clientNewer isPublished @ t=60 title @ t=30 Both merged Both merged Yes serverRecordUpdatedBeforeClientRecord
2 differentFieldsChange_conflictOnSend_serverNewer isPublished @ t=30 title @ t=60 Both merged Both merged Yes clientRecordUpdatedBeforeServerRecord
3 differentFieldsChange_conflictOnFetch_clientNewer isPublished @ t=60 title @ t=30 Both merged Both merged Yes serverAndClientEditDifferentFields
4 differentFieldsChange_conflictOnFetch_serverNewer isPublished @ t=30 title @ t=60 Both merged Both merged Yes New
5 differentNullableFieldsChange_conflictOnFetch_clientNewer priority @ t=60 dueDate @ t=30 Both merged Both merged Yes mergeWithNullableFields
6 sameFieldChange_conflictOnSend_retryBeforeFetch_clientNewer title @ t=60 title @ t=30 Client Client Yes New
7 sameFieldChange_conflictOnSend_retryBeforeFetch_serverNewer title @ t=30 title @ t=60 Server Client No New
8 sameFieldChange_conflictOnSend_fetchBeforeRetry_clientNewer title @ t=60 title @ t=30 Client Client Yes serverRecordEditedBeforeClientButProcessedAfterClient
9 sameFieldChange_conflictOnSend_fetchBeforeRetry_serverNewer title @ t=30 title @ t=60 Server Client No New (originally proposed in #353)
10 sameFieldChange_conflictOnSend_equalValues_clientNewer title @ t=60 Same title @ t=30 Same value, title @ t=60 Same value, title @ t=30 No New
11 sameFieldChange_conflictOnSend_equalValues_serverNewer title @ t=30 Same title @ t=60 Same value, title @ t=60 Same value, title @ t=60 Yes New
12 sameFieldChange_conflictOnSend_equalTimestamps title @ t=60 title @ t=60 Client Client Yes New
13 sameFieldChange_conflictOnFetch_clientNewer title @ t=60 title @ t=30 Client Client Yes serverRecordEditedAndProcessedBeforeClient
14 sameFieldChange_conflictOnFetch_serverNewer title @ t=30 title @ t=60 Server Client No serverRecordEditedAfterClientButProcessedBeforeClient
15 sameFieldChangeAndRemoval_conflictOnSend_clientNewer Nulls body @ t=60 Changes body @ t=30 Client Client Yes New
16 sameFieldChangeAndRemoval_conflictOnSend_serverNewer Nulls body @ t=30 Changes body @ t=60 Server Client No New
17 sameFieldRemoval_conflictOnSend_clientNewer Nulls body @ t=60 Nulls body @ t=30 Both nil, body @ t=60 Both nil, body @ t=30 No New
18 sameFieldRemoval_conflictOnSend_serverNewer Nulls body @ t=30 Nulls body @ t=60 Both nil, body @ t=60 Both nil, body @ t=60 Yes New

Next Steps

  • A follow-up PR with a minimal fix for the first issue is ready and can be submitted once this PR is merged.
  • The second issue needs a separate discussion. Fixing it requires the engine to recognize a client edit even when the server already holds the same value, which calls for an ancestor to compare against. Whether the last-known server record is suitable to play that role is one of the open questions for the conflict resolution work.

lukaskubanek added a commit to structuredpath/sqlite-data that referenced this pull request Mar 26, 2026
Introduce Post table type

Add differentFieldsChange_conflictOnSend_clientNewer test

Add differentFieldsChange_conflictOnSend_serverNewer test

Remove replaced tests

Add differentFieldsChange_conflictOnFetch_clientNewer test

Add differentFieldsChange_conflictOnFetch_serverNewer test

Add convergence assertions for local state

Remove replaced test

Add sameFieldChange_conflictOnSend_retryBeforeFetch_* tests

Add sameFieldChange_conflictOnSend_fetchBeforeRetry_* tests

Add sameFieldChange_conflictOnFetch_clientNewer test

Add sameFieldChange_conflictOnFetch_serverNewer test

Remove replaced tests

Add sameFieldChangeAndRemoval_* tests

Update test for updating nullable fields

Combine local database assertions into a single join query

Add sameFieldChange_conflictOnSend_equalTimestamps test

Add sameFieldRemoval_conflictOnSend_clientNewer test

Add sameFieldRemoval_conflictOnSend_serverNewer test

Improve synced row query asserts
`CKRecord.copyWithMockSystemFields(…)` returns a copy with the given values
stored in the real system fields, so they behave like server-assigned values
without swizzling. Supports `modificationDate` only for now and is not yet
used by `MockCloudDatabase`.
Records saved in a batch now receive an incrementing modification date,
mirroring the CloudKit server. The sync engine's modification date checks
therefore run in tests as they do in production, which changes an
expectation in one merge conflict test.
`MockCloudDatabase` now stores the change tag in the real system field as
well, while the sync machinery still reads `_recordChangeTag` for now.
`MockCloudDatabase` and `MockSyncEngine` now read `CKRecord.recordChangeTag`
from the record's system field instead of the `_recordChangeTag` mock field,
which is removed along with its `isTesting` persistence branches. Change tags
become strings but remain digit-based for now, to be replaced later.
Matches the format CloudKit uses for `CKRecord.recordChangeTag`.
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