Conversation
… own iCloud record
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
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
…layouts off iCloud (#3210)
…torage # Conflicts: # CHANGELOG.md # TablePro/Core/Sync/Extensions/SyncCoordinator+PushCollection.swift # TablePro/Core/Sync/Extensions/SyncCoordinator+RemoteDeletions.swift # TablePro/Core/Sync/SyncCoordinator.swift # TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift
Member
Author
|
Merged
Verified locally: 15 suites, 201 of 201 cases (sync, favorites, folders, boundary, settlement, echo). SwiftLint 0 violations. |
This branch was successfully deployed
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.
This branch now also carries #3210 (keep Local only connections' favorites, saved queries and layouts off iCloud), merged into it as 39936c2. Merging this PR lands both. The #3210 description covers that half.
Found while implementing #3167 (PR #3189). This PR carries three findings from that investigation plus the sync-push fundamentals the folder work and the next sync lane build on.
What was wrong
A record both dirty and tombstoned was pushed as a save and a deletion.
SyncChangeTracker.markDirtyleaves a pending tombstone in place, and the push appended every tombstone and every dirty record independently. Unstarring a table and starring it again before a sync, or renaming a table or database and renaming it back, put a save and a deletion of the same record into oneCKModifyRecordsOperation. What CloudKit does with that pair was not measured, so the CHANGELOG entry names what the app sent rather than a downstream symptom.A deletion of a record iCloud never had never settled on macOS.
PushOutcome.acceptMissingDeletionsexisted and only the iOS coordinator called it, so on macOS anunknownItemanswer to a deletion kept its tombstone and failed every later push until the 30-day prune.This Mac's own deletion could undo a re-add made during the push. The echo guard covered saved records only. Unstar a favorite, star it again while the deletion is in flight, and the pull that follows applied this Mac's own deletion to the re-added favorite and dropped its pending save.
Table favorites were moved one entry at a time (F3). Every catalog adoption path looped the single-entry mutators, and each call re-encoded the whole favorites set into
UserDefaults, marked one sync id and posted.favoriteTablesDidChange, which every window's sidebar answers with a reload. The tracker rewrites its whole tombstone list on each mark, so the cost grows quadratically. Measured in the test host, storage and tracker only, no windows attached:mainran)Every one of those posts also reloads each open window's sidebar, which the table leaves out. The pull had the same shape, one persist and post per pulled favorite.
FavoriteDatabasesStorage.renamewas two writes and two posts, andremoveFavorites(for:)tombstoned one id at a time.Two favorite tables could share one iCloud record (F5).
syncIdhasheduuid|database|schema|namejoined with a bare|, so('a|b', 'c', 't')and('a', 'b|c', 't')on one connection got the same record name. Starring one saved over the other, unstarring one deleted the other's record, and a peer receiving that deletion removed whichever entry aSetiteration reached first. Separately,FavoriteEntrytreated aniland an""database or schema as different entries while the sync id treated them as one, and the sidebar wrote the table's raw schema while rename and drop looked it up throughfavoriteSchema, so a table whose driver reported an empty schema lost its star on rename.SQL typed in a query tab (F4) reaches the catalog service only as
.statementsRan, so a rename or drop run there leaves favorites and table settings on the old name.What changed
Sync push, all record types
collectPushBatchsettles an id that is both dirty and tombstoned from local truth. When the record builds from local storage, the save goes, the deletion is withheld, and the id is recorded inSyncPushBatch.supersededTombstones. Once that save is acknowledged and the id was not edited again during the push, its tombstone is removed first and its dirty mark second, so an interruption between the two leaves a retryable save rather than a deletion. When the record does not build, the deletion goes and the dirty mark is discarded, as unbuildable marks already were. When a store cannot be read, a conflicted deletion is withheld and both marks are kept.markDirtyis unchanged: withdrawing the tombstone there loses a deletion whenever the dirty id no longer exists locally, because the push discards unbuildable dirty ids.performPushcallsacceptMissingDeletions, including on a push cut short by an interruption.SyncEchoGuardcarries the deletions a push completed. A pulled deletion of an id edited since the snapshot is withheld, the same rule the guard already applied to saves.One write per change (F3)
FavoriteTablesStoragehas one private commit path: load once, edit the whole set, diff it (removed = old minus new, added = new minus old), persist once, then one batchmarkDeleted, one batchmarkDirtyand one post, all outside the lock. Toggle, add, remove,removeFavorites(inDatabase:schema:),removeFavorites(for:)and the newretarget(connectionId:_:)all go through it, andTrackedActionis gone.CatalogEditAdoptiontakes both favorite storages by injection and makes one call per catalog change;moveFavoriteis gone.FavoriteDatabasesStoragegot the same diff commit, so a rename is one write and one post. A pull collects its table-favorite saves and applies them with the pull's deletions in one commit and one post, before a connection deleted in the same pull is purged, which is the order the per-record apply had.ConnectionLocalState.purgetakes the favorite storages by injection so that order is testable.One iCloud record per favorite (F5)
syncId(for:)hashesIdentityPath.joined([uuid, database ?? "", schema ?? "", name], separator: "|"). For every name without|or\that path is the old bare join, so the id is unchanged (golden testd9ddf339...a9d2). When escaping changed the path, the hash takes anescaped|domain prefix. Without it, an escaped path is the old bare join of some other entry:('a|b', 'c', 't')escapes to exactly the old preimage of('a\', 'b', 'c|t'). A legacy preimage always starts with a UUID, so a prefixed id can never meet one.FavoriteEntrycanonicalizesdatabaseandschemawith the publicnilIfEmptyfrom TableProPluginKit, in its initializer and its decoder. Loading merges entries that differed only bynilversus""and persists once.removeFavorites(inDatabase:schema:)normalizes its arguments. The sidebar writes the schema throughfavoriteSchema, the spelling rename and drop read.migrateSyncIdentityIfNeeded()runs fromAppDelegatebesidemigratePluginSecureFieldsIfNeeded(), beforeSyncCoordinator.start(). It marks the new id and the alias of every re-keyed favorite dirty, then records the version, so an interruption only repeats the marking. It never tombstones anything: a legacy deletion comes back in the same cycle's pull and a Mac on an older build applies it after the new record, losing the favorite for good. An unstar and restar just before upgrading leaves the alias both dirty and tombstoned, and the push settles that as a save.SyncRemoteDeletionEffects. The echo of this Mac's own two tombstones matches nothing.F4 is documented, not adopted
Parsing editor DDL to carry favorites and settings is unsafe: a failed statement still posts the event, transactional DDL can roll back without the service knowing, and names resolve against server state the app cannot see, while a wrong adoption deletes settings on every Mac.
docs/features/table-operations.mdxnow says what the user sees and to use the sidebar's Rename and Delete instead.Deviations from the plan
syncIdis kept for every name without|or\; escaped paths add theescaped|domain prefix above. Found in review: without it, a re-keyed favorite's record name can be another favorite's old one.Tests and verification
SyncPushSettlementTests(new, 7 cases): restar before a push sends the save alone and clears the tombstone; a dirty-then-tombstoned existing record is saved; a dirty-and-tombstoned absent record is deleted and its mark discarded; a second unstar during the save keeps the tombstone for the next push; an unreadable store withholds the conflicted deletion; anunknownItemdeletion settles; a database renamed and renamed back saves the original and deletes only the detour. Six of the seven fail with the push fixes reverted; the absent-record case passes onmaintoo and guards against settling this inmarkDirty.FavoriteTableSyncIdentityTests(new, 13 cases): a migrated favorite survives the echo of its own push and is saved under both ids; a restar just before the upgrade survives; an older build's deletion by the alias removes the favorite and tombstones the new id, for a migrated favorite and one starred after the upgrade; a favorite starred after the upgrade is saved under both ids; a shared alias is not published, and is republished for the survivor once one is removed; a local removal deletes both records and their echo changes nothing; a pulled alias record is retired on removal; a restar during the deletion survives its echo, plain and re-keyed; a connection deleted in the same pull outranks a favorite save; a pull's saves and deletions post one change.FavoriteTablesStorageTests(12 new cases): one post for 200 retargeted favorites, connection scoping, the rename round trip, merge onto an existing entry, no post for a no-op, one post for a container drop, an""database, canonical entries and decoding, migration marks only re-keyed favorites under both ids and is a no-op the second time, an ambiguous alias deletion, a shared alias outliving one removal.SyncRecordMapperFavoriteTableTests(5 new cases): golden id, the three measured colliding pairs, the escaped-path domain, a|and\round trip, an empty schema decoding as none.CatalogEditFavoriteAdoptionTests(new, 3 cases): renaming a database holding 200 favorites posts one table change and one database change; a table rename moves the star; a star written from an empty schema follows the rename.SyncTestEnvironmentandScriptedSyncTransportmoved out ofSyncCoordinatorEchoTestsintoTableProTests/Helpers, so the push, identity and echo suites share one fixture. The transport can answerunknownItemfor a deletion and echo pushed deletions back through the pull.Local runs, all through
.claude/skills/fix-issue/scripts/verify.sh:SyncCoordinatorEchoTests,SyncCoordinatorSQLFavoritePullTests,SidebarOutlineScaffoldTests,ConnectionLocalStatePurgeTests,TableScopedSettingsRegistryTests,FavoriteDatabasesStorageTestsand the rest).--stricton all 20 changed Swift files: 0 violations.No UI automation: the change is storage and sync plumbing with no new UI, and the UI-facing effect (one sidebar reload per change) is covered by the notification counts.
Security
A security review of the diff found nothing at confidence 8 or above.
Review
Codex ran four passes on the working tree.
escaped|domain); a batched pull applied favorite saves after purging a connection deleted in the same pull (restored the old order).Not verified: what CloudKit does with a save and a deletion of one record in one operation, and whether it answers
unknownItemwhen deleting a record it never had. The tests model the second throughScriptedSyncTransport, matching what the iOS coordinator already assumes.Not fixed here
nilschema, so a schema rename or drop still misses them. The commit path keeps the existing predicates. Normalizing favorite keys to the resolved scope is a follow-up, and it is a sync-id migration of its own.|joins found during the sweep, all local and out of scope:DatabaseEndpoint.id,CompareObjectResult.id, theSourceObjectDiffEnginekey, andLinkedFolderWatcher.stableId(re-keying that one orphans everything keyed by the linked connection id).SyncPendingDeletionsreturns early for.favoriteDatabase)..settingsbut never pushed, and a pulled settings deletion is ignored.