Skip to content

fix(sync): deliver Mac connection changes to iPhone and iPad (#643) - #1990

Open
datlechin wants to merge 7 commits into
mainfrom
fix/mac-to-ios-connection-sync
Open

fix(sync): deliver Mac connection changes to iPhone and iPad (#643)#1990
datlechin wants to merge 7 commits into
mainfrom
fix/mac-to-ios-connection-sync

Conversation

@datlechin

Copy link
Copy Markdown
Member

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-environment to Production, 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-schema against the live Production container shows the Connection record type is missing five fields the apps write:

Field Written by Since Condition
isFavorite macOS 2026-05-28 (#1452) every record
aiRules macOS 2026-05-07 when set
aiAlwaysAllowedTools macOS 2026-05-07 when set
tagIds both 2026-06-23 (#1753) when tagged
queryTimeoutSeconds iOS 2026-04-02 when set

isFavorite is 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.pushBatch passed perRecordSaveBlock a closure that only logged and discarded the result, so a partially rejected batch surfaced as a single top-level CKError.partialFailure. performPush() was declared async, not async throws, and its catch matched only .serverRecordChanged, so .partialFailure fell through to a bare log. syncNow() then pulled, stamped lastSyncDate, set .idle, and logged "Sync completed successfully" regardless. Settings showed a healthy sync forever.

What changed

Wire schema with a deploy gate. ConnectionSyncField is now the single declaration of every Connection wire key, and writes go through a gated CKRecord subscript 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.ckdb is checked in, scripts/export-cloudkit-schema.sh refreshes it, and ProductionSchemaParityTests fails 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 perRecordSaveBlock and perRecordDeleteBlock into a shared PushOutcome. A partialFailure is 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 .error without stamping a fresh last-synced time.

Field-level merge. The Mac built a fresh CKRecord every push and assigned every field, so with .changedKeys it overwrote the entire server record and silently clobbered any not-yet-pulled edit from the phone. A new SyncRecordCache persists 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 moreComing flag 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 same color field.

Removed the dead conflict machinery. ConflictResolutionView (206 lines) had no presentation site anywhere, and handlePushConflicts was gated on a top-level .serverRecordChanged that .changedKeys can never produce. With automatic field-level merge the prompt is not the right model, and no comparable client ships one. Deleted along with ConflictResolver and the unused shared SyncConflict.

Structure

CloudKitSyncEngine, SyncMetadataStorage, SyncError, SyncRecordType and the new schema and cache types moved into a model-free TableProSyncTransport target that both apps link, so the wire contract has one definition. The two SyncMetadataStorage classes were deliberately left separate: their UserDefaults keys are byte-identical so there is no data risk, but merging them would mean changing @MainActor isolation 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 synthesized partialFailure, the record cache, field-level merge, and the first round-trip coverage the macOS Connection mapper 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 CKAccountStatus cases instead of one Bool, and a "Refresh from iCloud" action to match iOS).

Fixes #643

https://claude.ai/code/session_013FgQFfRNr3JrLcqyGMv7DG

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@mintlify

mintlify Bot commented Jul 29, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Jul 29, 2026, 4:32 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

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.

bug: iCloud sync between macOS and iOS App

1 participant