fix(sync): deliver Mac connection changes to iPhone and iPad (#643) - #1990
Open
datlechin wants to merge 7 commits into
Open
fix(sync): deliver Mac connection changes to iPhone and iPad (#643)#1990datlechin wants to merge 7 commits into
datlechin wants to merge 7 commits into
Conversation
…n CloudKit schema (#643)
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Connection changes made on the Mac have never reached the iPhone and iPad app, while changes made on mobile reach the Mac immediately. Reported again in #643, which was closed with a "turn Sync off and on again" workaround rather than a root cause.
Root cause
Two independent faults, both proven.
1. The Mac sends fields the Production CloudKit schema does not hold. Both apps pin
com.apple.developer.icloud-container-environmenttoProduction, and CloudKit only creates fields automatically in the Development environment. No build, not even a local Debug one, can create a field on the server. Saving a record that carries an undeclared field makes CloudKit reject that record.xcrun cktool export-schemaagainst the live Production container shows theConnectionrecord type is missing five fields the apps write:isFavoriteaiRulesaiAlwaysAllowedToolstagIdsqueryTimeoutSecondsisFavoriteis unconditional, so every connection the Mac has tried to send since 2026-05-28 has been rejected. iOS keeps working because it never writes that field.2. Nothing could observe the failure.
CloudKitSyncEngine.pushBatchpassedperRecordSaveBlocka closure that only logged and discarded the result, so a partially rejected batch surfaced as a single top-levelCKError.partialFailure.performPush()was declaredasync, notasync throws, and itscatchmatched only.serverRecordChanged, so.partialFailurefell through to a bare log.syncNow()then pulled, stampedlastSyncDate, set.idle, and logged "Sync completed successfully" regardless. Settings showed a healthy sync forever.What changed
Wire schema with a deploy gate.
ConnectionSyncFieldis now the single declaration of everyConnectionwire key, and writes go through a gatedCKRecordsubscript that refuses any field not verified against the Production schema. A newly added case defaults to.unverified, so the next field added without a deploy is inert rather than destructive. The five undeployed fields are gated off.A real guard, not an honour system.
CloudKit/production-schema.ckdbis checked in,scripts/export-cloudkit-schema.shrefreshes it, andProductionSchemaParityTestsfails in both directions: if a writable field is absent from Production, and if a gated field is actually deployed and should be turned back on. That second check is what stops the gating becoming permanent by accident. The rule and the deploy sequence are documented in CLAUDE.md's Invariants.Per-record push accounting. Both engines read
perRecordSaveBlockandperRecordDeleteBlockinto a sharedPushOutcome. ApartialFailureis treated as data rather than an exception. Only records CloudKit confirms saved get their dirty flag cleared, and only confirmed deletes drop their tombstone, so one rejected record no longer discards the rest of the batch.performPush()throws, and a failed push reports.errorwithout stamping a fresh last-synced time.Field-level merge. The Mac built a fresh
CKRecordevery push and assigned every field, so with.changedKeysit overwrote the entire server record and silently clobbered any not-yet-pulled edit from the phone. A newSyncRecordCachepersists the last known server copy; pushes build on it and only assign fields whose value actually differs. On the pull side, a locally dirty record whose server copy also changed is now three-way merged against that cached base rather than kept wholesale, which used to leave the Mac's own model stale and push the stale value back.Pagination. Both engines discarded CloudKit's
moreComingflag and treated page one as the whole result while still advancing the change token past everything. Both now drain the zone. iOS persists the token only after the merge is written to disk.Two safety fixes found on the way. The Mac decoded any unrecognised safe-mode value as
.silent, the least protective level, so a read-only connection synced from iOS arrived with no protection at all; it now maps the iOS vocabulary and falls back to requiring confirmation. Both platforms also wrote incompatible vocabularies into the samecolorfield.Removed the dead conflict machinery.
ConflictResolutionView(206 lines) had no presentation site anywhere, andhandlePushConflictswas gated on a top-level.serverRecordChangedthat.changedKeyscan never produce. With automatic field-level merge the prompt is not the right model, and no comparable client ships one. Deleted along withConflictResolverand the unused sharedSyncConflict.Structure
CloudKitSyncEngine,SyncMetadataStorage,SyncError,SyncRecordTypeand the new schema and cache types moved into a model-freeTableProSyncTransporttarget that both apps link, so the wire contract has one definition. The twoSyncMetadataStorageclasses were deliberately left separate: their UserDefaults keys are byte-identical so there is no data risk, but merging them would mean changing@MainActorisolation and the record-type set across the Mac app for no user-visible gain, inside a data-path fix.Tests
31 tests in
TableProSyncTests, including the schema parity guard, per-record push accounting against a synthesizedpartialFailure, the record cache, field-level merge, and the first round-trip coverage the macOSConnectionmapper has ever had.One limitation worth stating: there is no test asserting "an unchanged field is not marked changed", because
CKRecord.changedKeys()survives archiving, so a locally built record can never present an empty baseline. That was verified with a standalone probe; the equality predicate and base-record reuse are tested directly instead.Follow-up, not in this PR
Deploy those five fields in CloudKit Console, re-run
scripts/export-cloudkit-schema.sh, and flip them to.verified. That restores favorites, multi-tag, AI rules and query-timeout syncing. Until then they simply do not propagate, which is the safe half of the trade. The parity test will fail once they are deployed, as a reminder to turn them back on.Also still open: the honest status surface on macOS (a never-synced state, real
CKAccountStatuscases instead of one Bool, and a "Refresh from iCloud" action to match iOS).Fixes #643
https://claude.ai/code/session_013FgQFfRNr3JrLcqyGMv7DG