3.0.2: fix Core Data legacy migration + stop destroying user data#7
Merged
Conversation
Root cause for users on 2.0 → 3.0 → 3.0.1 who don't see their dittos: - 2.0 stored content in App Group UserDefaults under "dittos" as a flat [String] (the only earlier source we have in git, the v1 tag's Ditto/DittoStore.swift, persists exactly this shape and the 2.0 build is a direct descendant of it). - 3.0.0 shipped a Core Data migrator. For these users `legacyStoreURL` is nil (no SQLite, only NSUserDefaults), so `needsMigration` is false, `migrateIfNeeded` never runs, and the `legacyCoreDataMigrationComplete` flag is never written. Clean. - 3.0.1 shipped an NSUserDefaults migrator under a brand-new flag (`legacyUserDefaultsMigrationComplete`), so 3.0.0 didn't pollute the gate. But `readLegacyCategories` only recognized the categorized shapes (dict-by-title, parallel arrays) — it returned [] for the flat shape, `needsMigration` stayed false, and the migrator silently no-op'd on every launch. Fixes: - Add the flat [String] shape to the recognizer. When matched, the whole list is migrated into a single "Imported" category so users can sort it later. The new shape is logged at .debug; previously unrecognized formats are logged at .error with the raw types so field debugging can identify any remaining variants. - Add a manual "Recover Old Dittos" menu item to the main list. It appears whenever `previewRecoverableData()` finds a recoverable blob in NSUserDefaults *regardless of the completion flag*, so users whose first 3.0 launch missed them can still recover after the fact. The flow: tap → confirmation alert showing X dittos / Y categories → "Recover" merges into the live store (skipping duplicates) → result alert reports the new-insert count. - Tests cover the flat shape, recoverNow ignoring the completion flag, and dedup against the existing SwiftData store.
The 3.0.0 migrator (commit e8704d7) shipped two bugs that combined to delete v2.x users' on-disk dittos: 1. LegacyDataMigrator.readLegacyStore did Bundle.main.url(forResource: "Ditto", withExtension: "momd") but the .xcdatamodeld was only added to the keyboard extension target's Resources build phase, not the main app's. On a real device the URL was nil, the guard fell through, and the function returned [] without throwing. 2. migrateIfNeeded interpreted the empty result as "nothing to migrate" and called cleanupLegacyFiles, which iterates ["", "-shm", "-wal", "-journal"] and removeItem's each one. So the same launch that couldn't read the store also deleted Ditto.sqlite and its WAL siblings at <groupURL>/Ditto.sqlite. 3.0.1 (the NSUserDefaults rewrite) didn't make things worse — it never deleted anything — but it was reading the wrong source, so it couldn't recover the users that 3.0.0 had already wiped. Fixes: - Bundle Ditto.xcdatamodeld into the main app target's Resources build phase (new PBXBuildFile entry pointing at the existing fileRef, added to FA70EAA919F5A49D00960EE2). Bundle.main can now load it. - Replace the NSUserDefaults migrator with a Core Data migrator that reads <App Group container>/Ditto.sqlite read-only (also probes a few common pre-NSPersistentContainer paths as a safety net), walks Profile → ordered categories → ordered dittos, and maps use_count -> useCount on DittoItem. Schema and store path verified against commit 60f395d (the 2.0.1 ship). - Never call removeItem on the legacy SQLite, on any branch. Even successful migration leaves the source files in place — losing a few KB of disk is dramatically better than the alternative. - Bump the completion-flag key to legacyCoreDataMigrationComplete_v302 so every device that hit 3.0.0 or 3.0.1 retries the corrected migration on first 3.0.2 launch. - Wire the existing "Recover Old Dittos" menu item to the same path (LegacyDataMigrator.recoverNow), bypassing the flag, so users can re-trigger after they restore their device from an iCloud backup. - Bump CFBundleShortVersionString to 3.0.2 in both app and keyboard. - Add docs/RECOVERING_LOST_DITTOS.md with step-by-step user-facing recovery instructions (try 3.0.2's menu item, then iCloud restore, then the keyboard-extension trick). - Tests rewritten to build a real v2-schema SQLite fixture and verify the Profile → categories → dittos walk and the use_count read.
A user reports 578 KB of Documents & Data still attributed to Ditto in Settings → iPhone Storage. That's well above the ~50-150 KB baseline of a fresh SwiftData install, which strongly suggests the v2 Ditto.sqlite is still physically on disk in the App Group container — the 3.0.0 `cleanupLegacyFiles` used `try? removeItem`, so any of file-protection, permission, or open-handle conflicts would have failed silently. To maximize the chance 3.0.2 recovers these users, and to surface the state of the container in support sysdiagnoses: - `legacyStoreURL` now logs a full inventory of every file under the App Group container (and the app's Application Support / Documents / Library) with name and size at .info level. Console.app filtered to subsystem `io.kern.ditto`, category `LegacyDataMigrator` shows exactly what survived 3.0.0's cleanup on any given device. - The candidate list is widened: case-variant filenames (Ditto.sqlite, ditto.sqlite, Ditto.SQLite) and additional in-container subdirectories (Library/Application Support, Library/Application Support/Ditto, Documents). If multiple candidates exist, the largest is preferred — which handles the partial-deletion case where 3.0.0 zero-truncated one path but the real data lived at another. - `recoverNow` now returns a `RecoveryResult` enum (`nothingOnDisk` | `foundButUnreadable(String)` | `emptyStore` | `inserted(Int)`) so the menu alert can surface *why* a recovery produced zero results instead of silently saying "0 dittos imported". The "Recover Old Dittos" menu item now uses `hasRecoverableLegacyData` (any candidate file present, regardless of readability) as the visibility gate, and runs the recovery immediately if `previewRecoverableData` can't open the store — so users whose store is on disk but corrupt see an actionable error instead of a missing menu item.
…ll note
- LegacyDataMigrator now emits a single-line, .public, no-PII outcome
tag at every terminal exit of an auto- or manual-migration attempt:
migration_outcome source=<auto|manual> outcome=<code>
categories=N dittos=N inserted=N
Outcome codes: success | no_new_data | empty_store | found_unreadable
| nothing_on_disk. Aggregate TestFlight sysdiagnoses with:
log show ... | grep migration_outcome
to see the distribution of "data still recoverable" vs "cleanup
actually succeeded" vs "file present but unreadable" across the
field. All fields are .public so they survive sysdiagnose redaction
on non-developer devices.
- docs/RECOVERING_LOST_DITTOS.md gets a TestFlight-specific callout:
install over the existing App Store build (do NOT delete it first),
otherwise iOS wipes the per-app container and recovery becomes
impossible. The 578 KB user is on 3.0.1 from the App Store today
and the safe upgrade path is in-place replacement, not a fresh
install.
When 3.0.0's cleanup runs `try? removeItem` four times in a row over the SQLite main, -shm, -wal, and -journal sidecars, it's plausible some of those removals succeed while others fail silently — leaving a `.sqlite-wal` or `.sqlite-shm` on disk with no matching main DB. SQLite itself refuses to open a WAL without a matching main DB (the WAL header's salt fields must match a header the new app doesn't have), so this state isn't recoverable with the stock `NSPersistentStoreCoordinator` path. It *would* be recoverable with a custom WAL-frame decoder that walks pages directly, but that's a real chunk of work to write defensibly and we shouldn't invest in it speculatively. Add detection only: if we don't find a main `.sqlite` candidate but we do find an orphan `.sqlite-wal` or `.sqlite-shm` in the App Group container, log a `migration_outcome source=discovery outcome=wal_orphan` tag plus the path and size at .info. The TestFlight cohort signal will then tell us whether this bucket is large enough to justify writing the WAL-frame extractor.
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.
Root cause
3.0.0's
LegacyDataMigrator(commite8704d7) destroyed v2.x users' on-disk dittos because of two coupled bugs:The Core Data model wasn't in the main app's bundle.
e8704d7addedLegacyDataMigrator.swiftto the Sources build phase but did not addDitto.xcdatamodeldto the main app target's Resources build phase — the xcdatamodel was (and still is on master) only in the keyboard extension's Resources phase. So at runtime:returned
nilin the main app, the model-load guard fell through, andreadLegacyStorereturned[]without throwing.The "no data found" branch deleted the SQLite. Once
readLegacyStorereturned empty,migrateIfNeededran:The cleanup used
try? FileManager.removeItem(at:), which silently swallows errors. For some users it succeeded and they lost the SQLite outright; for others (e.g. keyboard extension holding an open handle, file protection, transient I/O error) it silently failed and the data is still physically present.3.0.1's NSUserDefaults migrator didn't make this worse (it never deletes anything) but it was reading the wrong source — the 2.0.1 ship (commit
60f395d) confirms data lives incontainerURL(forSecurityApplicationGroupIdentifier: "group.io.kern.ditto").appendingPathComponent("Ditto.sqlite")as Core Data, not NSUserDefaults.Field signal
A user wrote in reporting 578 KB of Documents & Data still attributed to Ditto under
Settings → General → iPhone Storage → Ditto. A fresh-install 3.0.x SwiftData container with just the preset categories sits in the ~50–150 KB range, so ~580 KB is well above baseline and strongly suggests the v2Ditto.sqlite(or its WAL/SHM siblings) is still on disk on at least some affected devices. Thetry?-swallowed-errors theory matches — likely keyboard-extension-held file handles. That informed the hardening pass in3b3e35dand the telemetry tag ind7248ce. Whether this is rare or common won't be known until we have field data from a TestFlight rollout (see test plan below).What 3.0.2 changes
Migrator (data path)
Ditto.xcdatamodeldinto the main app's Resources (newPBXBuildFilepointing at the existing fileRef, added toFA70EAA919F5A49D00960EE2).Bundle.maincan now load it.LegacyDataMigratorto open the legacy SQLite read-only, walkProfile→ orderedcategories→ ordereddittos, and mapuse_count→useCountonDittoItem. Schema and store path verified against commit60f395d(the 2.0.1 ship).removeItemon the legacy files, on any branch. Even successful migration leaves the SQLite where it is. A few KB of disk is cheap; losing user data is not.legacyCoreDataMigrationComplete_v302, so any device that hit 3.0.0 or 3.0.1 retries the corrected migration on first 3.0.2 launch — no menu tap required if the data is still on disk.Wider probe + observability (hardening for the 578 KB user)
legacyStoreURLnow probes case-variant filenames (Ditto.sqlite,ditto.sqlite,Ditto.SQLite) and additional in-container subdirectories (Library/Application Support,Library/Application Support/Ditto,Documents). When multiple candidates exist, the largest matching file is preferred — which handles the partial-cleanup case where 3.0.0 zero-truncated one path while the real data survived at another.legacyStoreURLruns, it logs a full inventory of every file under the App Group container (plus the app's Application Support / Documents / Library) with name and size at.infolevel. Affected users can capture this in a sysdiagnose so we can see exactly what survived 3.0.0's cleanup on their device.Field telemetry (
d7248ce).publictag:success|no_new_data|empty_store|found_unreadable|nothing_on_disk.success/no_new_data= cleanup silently failed for most users, data is recoverable) or an outlier (mostlynothing_on_disk= cleanup succeeded for most users, data is gone). Fields are.publicso they survive sysdiagnose redaction on non-developer devices.UI
Wire the "Recover Old Dittos" menu item to the same Core Data path. It ignores the completion flag, so users can re-trigger after restoring an iCloud backup.
The menu item is now visible whenever any legacy SQLite file is present (using
hasRecoverableLegacyData), even if the file turns out to be unreadable. Tapping it runsrecoverNow, which returns a structuredRecoveryResultso the alert can surface why nothing was recovered:.nothingOnDisk.foundButUnreadable(detail).emptyStore.inserted(0).inserted(N)CFBundleShortVersionString→ 3.0.2 inDitto/Info.plistandDittoKeyboard/Info.plist.Docs
docs/RECOVERING_LOST_DITTOS.mdwith step-by-step user-facing recovery instructions and a Console.app filter for verification.Recovery story per upgrade path
<groupURL>/Ditto.sqlite. 3.0.2 auto-migrates on first launch.How to verify on an affected device
With the iPhone connected to a Mac, open Console.app, select the device, filter by
subsystem:io.kern.ditto category:LegacyDataMigrator, then launch 3.0.2. The inventory dump shows exactly what's in the App Group container, e.g.:If the inventory shows a non-trivial
Ditto.sqlite, the data is still there and recovery will succeed. If it shows onlyDitto.store*(the SwiftData files) and no.sqlite, the cleanup did succeed on that device and iCloud restore is the only path.Tester onboarding (for the 578 KB user and any other beta testers)
Same App Group ID (
group.io.kern.ditto) and same Team ID across App Store and TestFlight distribution are required for the new build to see the existing container. If you've migrated developer accounts at some point, double-check both distributions are still on the same Team ID in App Store Connect.Test plan
<groupURL>/Ditto.sqlitebuilt from the v2 schema) and confirm SwiftData contains the imported categories/dittos with order preserved.legacyUserDefaultsMigrationComplete = true(the 3.0.1 key) on a device that still hasDitto.sqliteand confirm 3.0.2 auto-migrates because it uses a new flag key.Ditto.sqlite(v2 schema, populated) at the App Group container root and also atLibrary/Application Support/Ditto.sqlite, with different sizes. Confirm the migrator picks the larger one.Ditto.sqlitein the container. Confirm "Recover Old Dittos" menu item is still visible, the alert text is.foundButUnreadable(...)with a usable error string, and the outcome tag ismigration_outcome source=manual outcome=found_unreadable.inventory[appgroup] <size>B <relative-path>lines appear in Console at.infolevel underio.kern.ditto / LegacyDataMigrator.migration_outcome ...line is emitted at every terminal path (nothing_on_disk / found_unreadable / empty_store / no_new_data / success) and survives sysdiagnose collection on a non-developer device.<groupURL>/Ditto.sqliteis still on disk.legacyCoreDataMigrationComplete_v302 = trueto simulate a previously-migrated install, then place a fresh SQLite at<groupURL>/Ditto.sqlite, confirm "Recover Old Dittos" inserts only new dittos (duplicates skipped).Ditto.sqlite/-shm/-walare still on disk.migrateIfNeededmarks complete and returnsfalse, no menu item appears, no inventory contains a legacy SQLite, outcome tag isnothing_on_disk.DittoTests/LegacyDataMigratorTests.swiftexercises the v2 schema read on a temp-dir fixture).successwith non-zero inserted) before App Store submission.log collectfrom a handful of tester devices and grepmigration_outcometo see the rough distribution of outcomes across the field. Use that distribution to decide between "ship 3.0.2 widely with a hopeful tone" vs "ship with iCloud-restore as the headline recovery path".Closes the 3.0.0 data-loss regression. Companion user-facing instructions at
docs/RECOVERING_LOST_DITTOS.md.