Skip to content

Where: import/export backup (whole-database .zip)#11

Open
kyleve wants to merge 7 commits into
mainfrom
where-backup-import-export
Open

Where: import/export backup (whole-database .zip)#11
kyleve wants to merge 7 commits into
mainfrom
where-backup-import-export

Conversation

@kyleve
Copy link
Copy Markdown
Owner

@kyleve kyleve commented Jun 5, 2026

Summary

Adds a whole-database backup feature to the Where app, surfaced from a new Backup section in the Settings tab.

  • Export the entire SwiftData database (location samples, evidence metadata, manual days) plus evidence asset blobs into a single deflate-compressed .zip, then share it via the system share sheet (email / AirDrop / Files).
  • Import a backup .zip, choosing Merge (upsert into existing data) or Replace all (wipe first, then restore) at import time.
  • Plain (unencrypted) archive, protected by the transport.

Archive format

manifest.json         // versioned BackupArchive: samples + evidence + manual days + asset index
assets/<evidence-id>  // one file per evidence blob

Layering (UI never touches SwiftData)

  • WhereCore: extend WhereStore/SwiftDataStore with allEvidence(), allManualDays(), clearAll(); make DayPresence Codable; add BackupArchive + BackupService (zip codec); add WhereController.exportBackup() / importBackup(from:strategy:) (single perform transaction, security-scoped URL bracketing).
  • WhereUI: WhereModel bridges export/import with a BackupState + backupError; SettingsView adds Export (share sheet + temp cleanup) and Import (fileImporter -> merge/replace confirmationDialog -> success/error alert), both showing progress while in flight. New settings.backup.* string-catalog entries.

Dependency

  • Adds ZIPFoundation 0.9.20 to the WhereCore target (Foundation has no public zip reader). Committed as standalone groundwork, matching the existing swift-snapshot-testing external-SPM pattern.

Notes

  • Release builds are CloudKit-synced, so imports propagate to iCloud like any other store write.
  • Format is versioned (formatVersion) so future schema changes can refuse/migrate.

Test plan

  • tuist test WhereCoreTests — manifest + zip round-trip, non-zip rejection (BackupServiceTests); controller merge/replace round-trips, merge keeps pre-existing rows, replace wipes them, clearAll empties all tables (WhereControllerTests).
  • tuist test WhereUITests — model two-store round-trip + bogus-file failure (WhereModelBackupTests); backup string resolution + 3-arg import-summary ordering (StringsTests); SettingsView hosts the new section crash-free (ScreenHostingTests).
  • ./swiftformat --lint clean.
  • Manual: export -> email/save to Files; import with Merge and with Replace on a device.

Made with Cursor

kyleve and others added 7 commits June 5, 2026 13:56
Backup groundwork: extend WhereStore with allEvidence()/allManualDays()
(predicate-less reads) and clearAll() (full wipe for the replace import
strategy), implement them in SwiftDataStore, make DayPresence Codable so
it can ride in the backup manifest, and keep the ToggleFailingStore test
double conforming.

Closes plan step: store-readers.

Co-authored-by: Cursor <cursoragent@cursor.com>
Backup export/import needs a real .zip reader/writer; Foundation has no
public unzip API. Pin ZIPFoundation 0.9.20 (same external-SPM pattern as
swift-snapshot-testing) and link it into the WhereCore target. Unused
until the BackupService step; committed on its own as groundwork.

Closes plan step: package-dep.

Co-authored-by: Cursor <cursoragent@cursor.com>
Define the versioned, Codable BackupArchive (samples + evidence + manual
days + an asset index) and BackupService, which marshals a whole-database
backup to a deflate-compressed .zip (manifest.json + assets/<evidence-id>)
and reads one back into value types + blob bytes. Covered by round-trip
tests for the manifest JSON, the full zip, and a non-zip rejection.

Closes plan step: backup-types.

Co-authored-by: Cursor <cursoragent@cursor.com>
Expose the whole-database backup entry points: exportBackup() reads all
three tables plus evidence blobs and hands them to BackupService;
importBackup(from:strategy:) reads an archive (bracketing the
security-scoped document-picker URL) and writes it back in one perform
transaction, with .replace clearing the store first and .merge relying on
upsert semantics. ImportSummary reports the row counts. Covered by
merge/replace round-trip tests and a clearAll table-wipe test.

Closes plan step: controller-api.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add exportBackup()/importBackup(from:strategy:) plus a BackupState
(idle/exporting/importing) and a backupError channel so the Settings UI
can show progress, present the share sheet, and surface failures. Keep
the TestStore double conforming to the extended WhereStore. Covered by a
two-store round-trip and a bogus-file failure test.

Closes plan step: model-api.

Co-authored-by: Cursor <cursoragent@cursor.com>
New Backup section above the erase action: Export builds the .zip and
presents it in a UIActivityViewController share sheet (email / AirDrop /
Files), cleaning up the temp file on dismiss; Import opens a .zip via
fileImporter, then a merge/replace confirmation dialog, and reports a
success summary or error alert. Both rows show a spinner and disable
while work is in flight. Adds the settings.backup.* string catalog
entries and Strings accessors.

Closes plan step: settings-ui.

Co-authored-by: Cursor <cursoragent@cursor.com>
Round out the backup test coverage (BackupService, controller merge/
replace round-trips, clearAll, and the WhereModel bridge were added with
their respective steps): assert the new settings.backup.* catalog keys
resolve, and that the imported-summary message substitutes sample /
evidence / manual-day counts in the right order. Settings hosting stays
covered by the existing settingsViewHosts() test, which now builds the
new Backup section.

Closes plan step: tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
/// Serialize the entire store (all three tables plus evidence blobs) to a
/// `.zip` in the temporary directory and return its URL. The caller owns
/// the file: share it, then delete it (or its parent directory).
public func exportBackup() async throws -> URL {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this block the main thread at all? Or does it run on the background?

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.

1 participant