Fix CloudKit data loss on delete-then-reinsert#421
Fix CloudKit data loss on delete-then-reinsert#421jsutula wants to merge 4 commits intopointfreeco:mainfrom
Conversation
332871e to
1f5150a
Compare
1f5150a to
bdd291d
Compare
|
I've had a few rounds of refinement to make this a cleaner and more correct fix. |
| .update { | ||
| $0._isDeleted = false | ||
| $0.userModificationTime = $currentTime() | ||
| $0._lastKnownServerRecordAllFields = #bind(nil) |
There was a problem hiding this comment.
One note here: clearing _lastKnownServerRecordAllFields may be too blunt an instrument to get the desired result of fresh timestamps for every field. It does guarantee that the next set of changes sent to the server will have fresh timestamps for all fields, but I believe also introduces a window of time (after reinsert but before successful submission of changes) where concurrent modifications from another device could be fetched and applied with no delta-filtering baseline, potentially overwriting locally-reinserted values.
A more direct solution may call for a separate field on SyncMetadata, something like _isReinserted: Bool. Or better yet: roll up _isDeleted and _isReinserted as cases in a new optional enum field PendingStatus. The status being reinserted could then be used to control outbound change inclusion behavior (include changes for all fields, each modified at the row's userModificationTime) and inbound filtering behavior (only take server field value if its modified time is greater than or equal to local row's userModificationTime). I'll wait for feedback before pursuing this in case there may be other options.
Fixes #418
Root cause
Reinsertion of a record after a delete does not produce a new pending
.saveRecordchange entry due to missing "undelete" state detection in afterInsert trigger. No new.saveRecordmeans that only the.deleteRecordis propagated.Solution
To fulfill the expected behavior of reinsertion resulting in a
.saveRecordand assigning fresh timestamps to all fields, two-part solution:1. Prerequisite change necessary for writing accurate tests: Update
MockSyncEngineStateto deduplicate changes across types like the realCKSyncEngine.StateFrom
CKSyncEngine.State.add(pendingRecordZoneChanges:)documentation:MockSyncEngineState.add(pendingRecordZoneChanges:)deduplicates fully identical changes (type+ID) by virtue of the underlyingOrderedSet, but does not deduplicate when the type (save vs delete) is different, as is described above withCKSyncEngine.State.MockSyncEngineState.add(pendingDatabaseChanges:)has a similar issue. The fix: update both to deduplicate based on ID alone which will allow for the cross-type deduplication described above, and add tests.2. Primary change: Detect reinsertion of soft-deleted record and queue new
.saveRecordchangeFirst, update the
afterInserttrigger to partially reset theSyncMetadatarecord when reinsertion is detected (_isDeletedis true). Partial reset involves:_isDeletedback to falseuserModificationTime_lastKnownServerRecordAllFieldslastKnownServerRecordis intentionally not cleared in order to retain still valid record-level metadata like the change tag._lastKnownServerRecordAllFieldscan be cleared, introduce a new fallback inSyncEngine.nextRecordZoneChangeBatch(…)to allow use oflastKnownServerRecordwhen_lastKnownServerRecordAllFieldsis unavailableSecond, hook up a new
afterUndeleteTriggerto listen for this specific “undelete”/reset occurring. This trigger is responsible for queuing a.saveRecordchange viasyncEngine.$didUpdate(the same as what happens after a normal insert or update).Testing
In addition to new unit tests, performed a manual test with 9ea5bdd applied to verify that the data loss issue no longer occurs. Result (compare with before):
Screen.Recording.2026-03-13.at.12.32.44.AM.mov