fix(sync): keep Local only connections' favorites, saved queries and layouts off iCloud - #3210
Merged
datlechin merged 1 commit intoSep 30, 2026
Conversation
…layouts off iCloud
datlechin
added this pull request to stack #3211
September 30, 2026 01:03
datlechin
removed this pull request from stack #3211
September 30, 2026 10:35
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.
Stacked on #3201 (
fix/favorite-tables-storage). Review and merge that first; this branch's base is its head commit. Found while implementing #3167 (PR #3189, table folders).What was wrong
Marking a connection Local only (#3178) kept the connection record off iCloud and nothing else. Everything that belongs to the connection still went up:
collectTableFavoritespushed every dirty entry, whatever its connection.connection_idincluded, and filtered nothing.collectSettingspushed every dirty category.Sample connections leaked the same way, and the connection push only checked
localOnly, soenableSyncpushed the sample connection record too.Two remote deletions were dropped on the floor:
SyncPendingDeletions.parseansweredcase .favoriteDatabase, .settings: return, so a database favorite or a column layout removed on another Mac stayed here. That dates from #2184;FavoriteDatabasesStorage.removeFavoriteWithoutSync(id:)was written for it and only a test called it. Column layout deletions were tombstoned locally but never pushed either:collectSettingsnever looked at.settingstombstones, so they sat there until the 30-day prune.What changed
SyncBoundary(Core/Sync/SyncBoundary.swift) is the one answer to "does this record go to iCloud". It is built each cycle fromSyncSettings.syncs(_:)(#3179's exhaustive switch; there is no second one),SyncRecordType.verifiedInProductionand the connection store:includes(type): the category syncs and the type is deployed.includes(type, owner:): also refuses an owner inexcludedConnectionIds(localOnly || isSample, nowDatabaseConnection.participatesInSync).includes(tombstone, of:): the same question for a stored deletion.It is direction-agnostic on purpose: lane D reuses it on the pull. Owners: a connection is its own id; table favorites, database favorites, saved queries and folders use their connection id (nil means global, which always passes); a column layout uses its table scope's connection id; every other type has none.
Decision recorded: the brief asked for a boundary that is nil when the connection store cannot be read. This one is never nil; its
excludedConnectionIdsis nil instead, and then every owned record and owned tombstone is held while unowned ones still go up. One value answers both the type question and the owner question, so the two cannot disagree, and the push does not needsettingsand a boundary side by side.Push: push, hold or drop. Each collector returns a
SyncPushDispositionper dirty record. An excluded owner means.hold: the mark survives the cycle and goes up when the connection is back in sync. Only a record that cannot be built is dropped. This covers table favorites (both record ids of a favorite whose name holds|or\), database favorites (the ad hoc filter is gone), saved queries, folders and column layouts. The connection record itself keeps its existing rule, decided by its ownparticipatesInSyncflag: an excluded connection's own mark is dropped (localOnlyConnectionMarkIsDropped), because every path that puts a connection back in sync marks it dirty.Owner-held tombstones.
Tombstonegained an optionalowner(synthesizedCodable, so JSON written before this decodes with no owner, and an older build ignores the key). The dependent stores pass it throughSyncChangeTracker.markDeleted(_:ids:owner:)or, for a change spanning connections,markDeleted(_:idsByOwner:)(one write, one notification):FavoriteTablesStorage,FavoriteDatabasesStorage,SQLFavoriteManager(the storage now reports each deleted favorite's and folder's scope from the same actor call as the delete), andFileColumnLayoutPersister. A tombstone whose owner is excluded is held. A layout tombstone written before owners existed takes its owner from its own name, so a backlog of old layout deletions cannot leak either.This reverses the policy stated on
collectDatabaseFavorites("Tombstones are not filtered"). Pushing a Local only connection's deletions removes the favorite on Macs where that connection still syncs, which is a local action leaking out. They are now held and go up if the connection is put back in sync.Column layout deletions push.
.settingstombstones go through the same path as every other type, under the owner rule. A layout tombstone whose category this Mac still holds (cleared and set again under a build that never pushed layout deletions) is retired instead of sent, so the upgrade does not delete live layouts from iCloud. A clear, geometry reset or container drop whose file write fails now changes nothing and records no deletion; before, the tombstone went up while the file kept the layout.Deleting a Local only or sample connection purges its dependents through a new
ConnectionLocalState.Origin.localOnly: no tombstones, dirty marks discarded, and every tombstone still held for that owner is discarded. Without that last step the owner leaves the store, stops being excluded, and every held deletion goes up.deleteConnectionssplits a mixed selection into the two origins.Saved queries and folders live in an actor-backed SQLite store, so their purge finishes a moment after the connection has left the connection list. Until it does, the deleted connection's id stays excluded through a small persisted set (
SyncMetadataStorage.ownersKeptOffSync), released when the saved-query purge reports success. A push that runs in between, a failed SQLite delete, or a quit in that window therefore still holds the rows instead of uploading them. The set only applies to ids no longer in the connection list, so a connection that exists follows its own flag, andclearAllleaves it alone because it protects local data rather than describing an account.Pruning now runs at the end of a clean push, with the boundary that push used, and keeps a tombstone that push could not send (
pruneTombstones(olderThan:where:)). A deletion held for a Local only connection longer than 30 days survives until it can go up, and a connection put back in sync during the cycle cannot get its held deletion pruned before it is sent.Remote deletions applied. Database favorites through
removeFavoritesWithoutSync(ids:)(the old single-id helper, replaced by the batch form). Column layouts throughFileColumnLayoutPersister.removeWithoutSync(storageKeys:). A record name that carries its category resolves directly; a digest-shortened one (#2575) is matched by recomputing the local record names, which is the only case that scans the layout files. A layout file that cannot be written fails the pull (pullNotSaved) and leaves its dirty mark, so the deletion arrives again instead of being acknowledged over a file that still holds the layout. Both respectSyncSettings.syncs(_:)through #3179's gate inparse. They apply regardless of owner here; lane D adds owner gating on the pull side.One pull-side change, and why it is not a scope skip. A pulled column layout whose category this Mac has tombstoned is no longer applied, which is what every other record type already does. Without it, a pull that arrives before the deletion goes up restores the layout, and the stale-tombstone retirement above then drops the deletion for good. The skipped record is superseded by the local deletion, which is sent (or held and sent later), so advancing the token over it loses nothing.
Deliberately not in this PR
For PR #3189 (table folders)
Map
.tableFolderand.tableFolderIteminSyncSettings.syncs(_:), use the folder scope's connection id as the owner, replacefolderSyncConnections()withsyncBoundary(settings:)(its nil-when-unreadable rule isexcludedConnectionIds == nil), return.holdfromcollectTableFoldersfor an excluded owner, and pass the owner tomarkDeletedinTableFolderStorage.Tests
SyncBoundaryTests: type scope follows category and deployment; Local only and sample owners refused, synced and unknown ones admitted; a deleted connection still being purged stays refused while one that exists again follows its flag; an owner never overrides a category; an unreadable store holds owned records; tombstones by stored owner, and a legacy layout tombstone by its name.SyncLocalOnlyDependentsTests, throughSyncCoordinator.runSyncCycleonScriptedSyncTransport:deleteConnectiondiscards a Local only owner's tombstones and keeps a synced one's.SyncPendingDeletionsTests: database favorite and layout deletions parse, and are withheld by their switches.SyncMetadataStorageTests(package): owner round-trip, legacy JSON decodes with no owner, owner removal across types, pruning keeps an unpushable tombstone, owners kept off sync persist and surviveclearAll.Before and after: with the owner check, the two new remote-deletion calls and the
.localOnlypurge reverted in place (before the review fixes), 14 of the 22 new cases failed (every gating, purge, prune and remote-deletion case); the 8 that passed were the controls and the cases those three reverts do not reach.Verification
swift test --filter SyncMetadataStorageTests: 22 passedUI automation: none. The change is in the push and purge paths, with no new UI; the unit suites drive the whole sync cycle through a scripted transport.
Security
A security review of the diff, focused on whether anything a user marked Local only can still reach iCloud, found nothing at confidence 8 or above.
Review
Codex review
review-mumxprmw-e9gsgi(4 findings):ownersKeptOffSynckeeps the id excluded until the purge reports success.Codex adversarial review
review-mumyipiw-kg2cnhstopped on the Codex usage limit before reporting. Its reasoning headlines were followed up as leads; one held: pruning ran after the cycle with a fresh boundary, so a connection put back in sync mid-cycle could have its held, month-old deletion pruned before it was sent. Pruning now runs inside the push with that push's boundary (heldTombstoneSurvivesAScopeChangeMidCycle).The
code-reviewskill (high) replaced the adversarial pass (10 findings):discardTombstones(ownedBy:)missed layout tombstones written before owners existed; it now uses the same owner derivation as the boundary.markDeleted(_:idsByOwner:)wrote and notified once per owner; now once.markDetachedDirtyand its doc comment; moved. The new SQL delete reusesrun(_:bindings:), which is now internal..localOnlypurge does not push detached saved queries". A global saved query inside a Local only connection's folder moves to the root here; pushing that would change every other Mac, which is what deleting a Local only connection must not do. Listed under "not in this PR".syncIdsByConnectionexists in two stores". They group two unrelated entry types by two different id functions; a shared generic helper would need a protocol for three lines.